From 0c89ae538ef74b81f52c8107832d91c5b8666293 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 00:08:02 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20extract=20generic=20DAML=E2=86=92?= =?UTF-8?q?JS=20codegen=20helpers=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move shared codegen/publish helpers into @fairmint/canton-dev-tools so consumer repos (canton-assets) can stay thin. Adds codegen-js and prepare-release CLI commands; leaves bundle-dependencies and create-root-index in consumers for Phase 2. Co-authored-by: HardlyDifficult --- README.md | 60 +++- bin/canton-dev-tools | 6 +- scripts/prepare-release.ts | 283 +--------------- src/cli.ts | 27 ++ src/daml/codegen/codegen-js.ts | 141 ++++++++ src/daml/codegen/collapse-manifest.ts | 39 +++ src/daml/codegen/create-package-index.ts | 19 ++ src/daml/codegen/discover-codegen-packages.ts | 124 +++++++ src/daml/codegen/fix-splice-refs.ts | 157 +++++++++ src/daml/codegen/generated-output-helpers.ts | 192 +++++++++++ src/daml/codegen/generated-package-index.ts | 29 ++ src/daml/codegen/index.ts | 12 + src/daml/codegen/install-generated-deps.ts | 58 ++++ src/daml/codegen/update-generated-package.ts | 100 ++++++ src/daml/codegen/verify-package-imports.ts | 103 ++++++ src/daml/index.ts | 1 + src/daml/types.ts | 14 + src/prepare-release.ts | 311 ++++++++++++++++++ test/unit/daml/codegen.test.ts | 226 +++++++++++++ test/unit/scripts/canton-dev-tools.test.ts | 13 + 20 files changed, 1636 insertions(+), 279 deletions(-) create mode 100644 src/daml/codegen/codegen-js.ts create mode 100644 src/daml/codegen/collapse-manifest.ts create mode 100644 src/daml/codegen/create-package-index.ts create mode 100644 src/daml/codegen/discover-codegen-packages.ts create mode 100644 src/daml/codegen/fix-splice-refs.ts create mode 100644 src/daml/codegen/generated-output-helpers.ts create mode 100644 src/daml/codegen/generated-package-index.ts create mode 100644 src/daml/codegen/index.ts create mode 100644 src/daml/codegen/install-generated-deps.ts create mode 100644 src/daml/codegen/update-generated-package.ts create mode 100644 src/daml/codegen/verify-package-imports.ts create mode 100644 src/prepare-release.ts create mode 100644 test/unit/daml/codegen.test.ts diff --git a/README.md b/README.md index 2e008ba..4c26803 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,68 @@ npx canton-dev-tools check-dar-version-policy --all npx canton-dev-tools check-dar-version-policy --extra-policy-paths scripts/codegen,libs/splice npx canton-dev-tools check-upgrade-compat npx canton-dev-tools sync-splice-dars +npx canton-dev-tools codegen-js +npx canton-dev-tools prepare-release --changelog-repo Fairmint/canton-assets ``` `backup-dar` / version-policy / upgrade-compat skip `Test` packages by default. Pass `--package` with the daml.yaml name, source dir, or a fuzzy alias (e.g. `wrappedAssets`). +### `codegen-js` (Phase 1) + +Generic DAML → JS bindings steps for packages that declare `codegen.js` in `daml.yaml`: + +1. `dpm codegen-js` in each `generated/build/` (expects `prepare-build` already done) +2. Stamp generated `package.json` name/version from the repo root +3. Write per-package `index.js` / `index.d.ts` +4. Fix Splice namespace refs on generated `lib/` trees (optional `@fairmint/*` → `__bundled__` rewrite when present) + +**Still consumer-local (Phase 2):** `bundle-dependencies`, `create-root-index`, merged-lib verify lists. + +Optional publish suffixes in root `package.json` (multi-package repos): + +```json +{ + "cantonDevTools": { + "codegenPublishSuffixes": { + "OpenCapTableReports-v01": "reports", + "WrappedAssets-v01": null + } + } +} +``` + +`null` publishes as the root package name. A single codegen package defaults to the root name. + +Library imports: + +```ts +import { + runCodegenJs, + createPackageIndexes, + updateGeneratedPackagesFromRoot, + fixSpliceRefs, + collapseManifestLines, + verifyPackageImports, + applyGeneratedImportRewrites, +} from '@fairmint/canton-dev-tools/daml'; +``` + +Example consumer scripts: + +```json +{ + "scripts": { + "prepare-build": "canton-dev-tools prepare-build", + "codegen": "npm run build && canton-dev-tools codegen-js", + "prepare-release": "canton-dev-tools prepare-release", + "package:manifest": "… | canton-dev-tools collapse-manifest > generated/npm-manifest.txt", + "package:prep": "npm run codegen && npm run update-version && tsx scripts/bundle-dependencies.ts && tsx scripts/create-root-index.ts && tsx scripts/fix-splice-refs.ts" + } +} +``` + +Prefer calling library helpers from thin consumer scripts when you need a custom step order (assets: bundle → create-root-index → fix-splice-refs on merged `lib/`). + ### `check-dar-version-policy` extra watch paths By default, auto-selection only treats package `daml.yaml` / `daml/` sources and `dars//` @@ -97,7 +155,7 @@ Optional overrides, in order: ### Library import ```ts -import { prepareBuild, discoverManagedPackages } from '@fairmint/canton-dev-tools/daml'; +import { prepareBuild, discoverManagedPackages, runCodegenJs } from '@fairmint/canton-dev-tools/daml'; ``` ## TypeScript helpers diff --git a/bin/canton-dev-tools b/bin/canton-dev-tools index aa2bda9..3d46844 100755 --- a/bin/canton-dev-tools +++ b/bin/canton-dev-tools @@ -49,10 +49,14 @@ DAML package commands (from a multi-package repo root): check-upgrade-compat sync-splice-dars [--config ] [--force] install-dpm-sdks + codegen-js [--root ] [--skip-dpm] + prepare-release [--root ] [--changelog-repo owner/repo] + collapse-manifest One-liners: npx @fairmint/canton-dev-tools start npx @fairmint/canton-dev-tools prepare-build + npx @fairmint/canton-dev-tools codegen-js Environment: CANTON_LOCALNET_QUICKSTART_DIR Use an existing cn-quickstart/quickstart directory @@ -212,7 +216,7 @@ main() { fi exit 0 ;; - prepare-build | verify-dars | backup-dar | check-dar-version-policy | check-upgrade-compat | check-upgrade-compatibility | sync-splice-dars) + prepare-build | verify-dars | backup-dar | check-dar-version-policy | check-upgrade-compat | check-upgrade-compatibility | sync-splice-dars | codegen-js | prepare-release | collapse-manifest) shift || true run_daml_cli "${command}" "$@" ;; diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index e2e0a45..8017a92 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -1,290 +1,19 @@ #!/usr/bin/env node /** - * Prepare Release Script - * - * Prepares a new release by selecting the next version and prepending CHANGELOG.md. - * - * Usage: npm run prepare-release - * - * Version selection (ocp-canton-sdk / ui-style floor): - * - If package.json version is ahead of npm latest (or npm returns 404) and is free, - * publish that version exactly (first publish = package.json, currently 0.1.0). - * - Otherwise patch-increment from the higher of npm latest / package.json, skipping - * versions that already exist on npm or as git tags. - * - * Rewrites package.json version in the CI workspace only — does not commit it back. + * Thin wrapper kept for `npm run prepare-release` in this package. + * Prefer `canton-dev-tools prepare-release` from consumer repos. */ -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; +import { prepareRelease } from '../src/prepare-release'; -interface PackageJson { - name: string; - version: string; - [key: string]: unknown; -} - -interface ParsedVersion { - major: number; - minor: number; - patch: number; -} - -/** Check if a git tag exists */ -function tagExists(tag: string): boolean { - try { - execSync(`git rev-parse "refs/tags/${tag}"`, { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -/** Encode a scoped package name for the npm registry HTTP API. */ -function encodePackageNameForRegistry(packageName: string): string { - return packageName.replace('/', '%2f'); -} - -/** - * Read published versions from the public registry HTTP API. - * - * Prefer this over `npm view` when a classic auth token in npmrc can 404 public - * packages the token cannot read (npm reports that as 404, not 403). - */ -function getNpmMetadataFromRegistry(packageName: string): { - latest: string | null; - versions: Set; -} | null { - try { - const encodedName = encodePackageNameForRegistry(packageName); - const result = execSync(`curl -fsS "https://registry.npmjs.org/${encodedName}"`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - const metadata = JSON.parse(result) as { - 'dist-tags'?: { latest?: string }; - versions?: Record; - }; - const versions = new Set(Object.keys(metadata.versions ?? {})); - const latest = metadata['dist-tags']?.latest ?? null; - return { latest, versions }; - } catch { - return null; - } -} - -/** Get all published versions from NPM registry */ -function getAllNpmVersions(packageName: string): Set { - try { - const result = execSync(`npm view "${packageName}" versions --json`, { - encoding: 'utf8', - }).trim(); - const versions = JSON.parse(result) as string | string[]; - if (Array.isArray(versions)) { - return new Set(versions); - } - return new Set([versions]); - } catch { - // Package may not exist on NPM yet (404), or auth may hide a public package. - return new Set(); - } -} - -/** Get the latest version from NPM registry */ -function getLatestNpmVersion(packageName: string): string | null { - try { - const result = execSync(`npm view "${packageName}" version`, { encoding: 'utf8' }).trim(); - return result || null; - } catch { - // Package may not exist on NPM yet (404), or auth may hide a public package. - return null; - } -} - -/** Parse version string into components */ -function parseVersion(version: string): ParsedVersion | null { - const parts = version.split('.').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { - return null; - } - if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { - return null; - } - return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! }; -} - -/** Compare two parsed semantic versions. */ -function compareVersions(left: ParsedVersion, right: ParsedVersion): number { - if (left.major !== right.major) return left.major - right.major; - if (left.minor !== right.minor) return left.minor - right.minor; - return left.patch - right.patch; -} - -/** Find the next available version by incrementing patch until free on tags and npm */ -function findNextAvailableVersion( - isVersionTaken: (version: string) => boolean, - major: number, - minor: number, - startPatch: number -): string { - let patch = startPatch; - let version: string; - - do { - patch++; - version = `${major}.${minor}.${patch}`; - } while (isVersionTaken(version)); - - return version; -} - -/** - * Select the version to publish. - * - * A manifest version newer than the latest NPM version (including first publish when npm is - * missing) is an explicit release boundary, so publish it unchanged when it is available. - * Once that version exists, normal patch increments resume from the highest baseline. - */ -function selectReleaseVersion( - manifestVersion: string, - latestNpmVersion: string | null, - isVersionTaken: (version: string) => boolean -): string { - const manifestParsed = parseVersion(manifestVersion); - if (!manifestParsed) { - throw new Error('Invalid version format in package.json. Expected format: x.y.z'); - } - - const npmParsed = latestNpmVersion ? parseVersion(latestNpmVersion) : null; - const manifestAheadOfNpm = !npmParsed || compareVersions(manifestParsed, npmParsed) > 0; - - if (manifestAheadOfNpm && !isVersionTaken(manifestVersion)) { - return manifestVersion; - } - - const baseline = - npmParsed && compareVersions(npmParsed, manifestParsed) > 0 ? npmParsed : manifestParsed; - return findNextAvailableVersion(isVersionTaken, baseline.major, baseline.minor, baseline.patch); -} - -/** - * Prepare release by selecting version and generating changelog. - * Safe for local testing (no git tag / push operations). - */ -function prepareRelease(): void { +if (require.main === module) { try { - const packageJsonPath: string = path.join(process.cwd(), 'package.json'); - const packageJson: PackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - - const packageName: string = packageJson.name; - const currentVersion: string = packageJson.version; - console.log(`Package: ${packageName}`); - console.log(`Current version in package.json: ${currentVersion}`); - - console.log('Fetching published versions from NPM...'); - let npmVersions = getAllNpmVersions(packageName); - let latestNpmVersion = getLatestNpmVersion(packageName); - - // Authenticated npmrcs can make `npm view` 404 public packages. Fall back once - // to the public registry HTTP API so we do not republish an existing version. - if (!latestNpmVersion && npmVersions.size === 0) { - const registryMetadata = getNpmMetadataFromRegistry(packageName); - if (registryMetadata) { - console.log('npm view returned no versions; using public registry HTTP metadata instead'); - npmVersions = registryMetadata.versions; - latestNpmVersion = registryMetadata.latest; - } - } - - if (latestNpmVersion) { - console.log(`Latest version on NPM: ${latestNpmVersion}`); - console.log(`Total published versions: ${npmVersions.size}`); - } else { - console.log('No version found on NPM (new package or registry unavailable)'); - } - - const isVersionTaken = (version: string): boolean => - npmVersions.has(version) || tagExists(`v${version}`); - const newVersion: string = selectReleaseVersion( - currentVersion, - latestNpmVersion, - isVersionTaken - ); - - console.log(`New version: ${newVersion}`); - - packageJson.version = newVersion; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - - console.log('✅ Updated package.json with new version'); - - let commits: string; - let lastTag: string | null = null; - try { - lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { - encoding: 'utf8', - }).trim(); - console.log(`Last tag: ${lastTag}`); - commits = execSync(`git log --oneline --format="%s" ${lastTag}..HEAD`, { - encoding: 'utf8', - }).trim(); - } catch { - // No previous tag (first publish on main): take recent history, not main..HEAD - // which is empty when HEAD is already on main. - console.log('No previous tag found, using recent commit history'); - commits = execSync('git log --oneline --format="%s" -n 20', { - encoding: 'utf8', - }).trim(); - } - - if (!commits) { - console.log('No commits found for changelog, using placeholder'); - commits = 'Initial release'; - } - - const commitLines: string[] = commits - .split('\n') - .map((commit: string): string => `- ${commit}`); - const changelog: string = commitLines.join('\n'); - - console.log('\n📋 Generated changelog:'); - console.log('='.repeat(50)); - console.log(changelog); - console.log('='.repeat(50)); - - const tagMessage = `Release v${newVersion}\n\nChanges:\n${changelog}`; - - console.log('\n🏷️ Tag message preview:'); - console.log('='.repeat(50)); - console.log(tagMessage); - console.log('='.repeat(50)); - - const changelogPath: string = path.join(process.cwd(), 'CHANGELOG.md'); - const previousVersionLink: string = lastTag - ? `\n[Previous version: ${lastTag}](https://github.com/Fairmint/canton-dev-tools/releases/tag/${lastTag})` - : ''; - - const changelogContent = `# Changelog for v${newVersion}\n\n${changelog}${previousVersionLink}\n\n`; - - if (fs.existsSync(changelogPath)) { - const existingChangelog: string = fs.readFileSync(changelogPath, 'utf8'); - fs.writeFileSync(changelogPath, changelogContent + existingChangelog); - } else { - fs.writeFileSync(changelogPath, changelogContent); - } - - console.log(`\n✅ Saved changelog to CHANGELOG.md`); - console.log(`\n🎯 Ready for release! CI will publish and tag v${newVersion}.`); + prepareRelease({ rootDir: process.cwd() }); } catch (error) { console.error('❌ Error preparing release:', (error as Error).message); process.exit(1); } } -if (require.main === module) { - prepareRelease(); -} - -export { prepareRelease, selectReleaseVersion }; +export { prepareRelease, selectReleaseVersion } from '../src/prepare-release'; diff --git a/src/cli.ts b/src/cli.ts index 33213cb..e8fa1f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,14 +5,18 @@ * Invoked by `bin/canton-dev-tools` for non-LocalNet commands. */ +import * as fs from 'node:fs'; import * as path from 'node:path'; import { runBackupDarCli } from './daml/backup-dar'; import { runCheckDarVersionPolicyCli } from './daml/check-dar-version-policy'; import { runCheckUpgradeCompatibilityCli } from './daml/check-upgrade-compatibility'; +import { runCodegenJsCli } from './daml/codegen/codegen-js'; +import { collapseManifestFromStdin } from './daml/codegen/collapse-manifest'; import { parseFlagValue } from './daml/packages'; import { prepareBuild } from './daml/prepare-build'; import { runSyncSpliceDarsCli } from './daml/sync-splice-dars'; import { runVerifyDarsCli } from './daml/verify-dars'; +import { runPrepareReleaseCli } from './prepare-release'; function usage(): void { console.log(`Usage: canton-dev-tools [options] @@ -25,10 +29,20 @@ DAML package commands (run from a multi-package repo root): check-upgrade-compat Run dpm upgrade-check against backups sync-splice-dars Fetch pinned Splice DARs (packaged default or splice-dars.json) install-dpm-sdks Install Daml SDKs from daml.yaml (shell helper) + codegen-js Run dpm codegen-js + generic post-steps + prepare-release Floor-style version bump + CHANGELOG.md + collapse-manifest Collapse npm pack paths from stdin Common options: --root Repo root (default: cwd) +codegen-js options: + --skip-dpm Only run post-processing (update/index/fix-splice-refs) + +prepare-release options: + --changelog-repo GitHub repo for previous-version changelog links + (default: package.json repository field) + check-dar-version-policy options: --all Check every managed package --package Check one package @@ -77,6 +91,19 @@ function main(): void { runSyncSpliceDarsCli(args); break; } + case 'codegen-js': { + runCodegenJsCli(args); + break; + } + case 'prepare-release': { + runPrepareReleaseCli(args); + break; + } + case 'collapse-manifest': { + const input = fs.readFileSync(0, 'utf8'); + console.log(collapseManifestFromStdin(input)); + break; + } default: console.error(`Unknown command: ${command}\n`); usage(); diff --git a/src/daml/codegen/codegen-js.ts b/src/daml/codegen/codegen-js.ts new file mode 100644 index 0000000..4d72011 --- /dev/null +++ b/src/daml/codegen/codegen-js.ts @@ -0,0 +1,141 @@ +/** + * Generic DAML → JS codegen orchestration (Phase 1). + * + * Runs `dpm codegen-js` for packages with codegen.js, then: + * update-generated-package → create-package-index → fix-splice-refs + * on generated JS trees. + * + * Consumer-specific steps stay local for Phase 2: + * - bundle-dependencies + * - create-root-index + */ + +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseFlagValue } from '../packages'; +import type { PackageJson } from '../types'; +import { createPackageIndexes } from './create-package-index'; +import { + discoverCodegenPackages, + readCodegenPublishSuffixes, + resolvePublishedPackageName, + type CodegenPackageConfig, +} from './discover-codegen-packages'; +import { fixSpliceRefs } from './fix-splice-refs'; +import { updateGeneratedPackagesFromRoot } from './update-generated-package'; + +export interface CodegenJsOptions { + rootDir: string; + /** Skip `dpm codegen-js` (only run post-processing). */ + skipDpm?: boolean; +} + +function dpmEnv(): NodeJS.ProcessEnv { + const homeBin = process.env['HOME'] ? path.join(process.env['HOME'], '.dpm', 'bin') : undefined; + const pathParts = [homeBin, process.env['PATH']].filter(Boolean); + return { ...process.env, PATH: pathParts.join(path.delimiter) }; +} + +function run(command: string, args: string[], cwd: string): void { + const result = spawnSync(command, args, { + cwd, + env: dpmEnv(), + stdio: 'inherit', + }); + if (result.error) { + throw result.error; + } + if (result.signal) { + throw new Error(`${command} ${args.join(' ')} terminated with signal ${result.signal}`); + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}`); + } +} + +export interface CodegenJsResult { + packages: CodegenPackageConfig[]; + updatedDirs: string[]; +} + +/** Run generic codegen-js steps for a multi-package DAML repo. */ +export function runCodegenJs(options: CodegenJsOptions): CodegenJsResult { + const rootDir = path.resolve(options.rootDir); + const packages = discoverCodegenPackages({ rootDir }); + + if (packages.length === 0) { + throw new Error( + `No packages with codegen.js found under ${rootDir}. ` + + 'Ensure prepare-build has run and daml.yaml declares codegen.js.' + ); + } + + if (!options.skipDpm) { + for (const pkg of packages) { + if (!fs.existsSync(path.join(pkg.absoluteBuildDir, 'daml.yaml'))) { + throw new Error( + `Missing prepared build for ${pkg.name} at ${pkg.absoluteBuildDir}. Run prepare-build first.` + ); + } + console.log(`Running dpm codegen-js for ${pkg.name}...`); + run('dpm', ['codegen-js'], pkg.absoluteBuildDir); + } + } + + const rootPackage = JSON.parse( + fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8') + ) as PackageJson; + if (!rootPackage.name) { + throw new Error(`Root package.json missing name at ${rootDir}`); + } + + const suffixes = readCodegenPublishSuffixes(rootDir); + const updateTargets = packages + .filter((pkg) => fs.existsSync(path.join(pkg.absoluteGeneratedJsDir, 'package.json'))) + .map((pkg) => ({ + dir: pkg.absoluteGeneratedJsDir, + publishedPackageName: resolvePublishedPackageName({ + rootPackageName: rootPackage.name!, + pkg, + suffixes, + codegenPackageCount: packages.length, + }), + })); + + const updatedDirs = updateGeneratedPackagesFromRoot({ + rootDir, + packages: updateTargets, + writeIndex: false, + }); + + createPackageIndexes({ packageDirs: updateTargets.map((target) => target.dir) }); + + for (const pkg of packages) { + if (!fs.existsSync(pkg.absoluteGeneratedLibDir)) { + console.log(`Skipping fix-splice-refs for ${pkg.name} (no lib yet)`); + continue; + } + // Generated trees are pre-bundle: namespace fix only (__bundled__ rewrite is a no-op). + fixSpliceRefs({ + targetDir: pkg.absoluteGeneratedLibDir, + rewriteFairmintScopedImports: true, + }); + } + + console.log( + `codegen-js complete for ${packages.map((pkg) => pkg.name).join(', ')}. ` + + 'Consumer steps still required: bundle-dependencies → create-root-index → ' + + 'fix-splice-refs (on merged lib/) → build:ts.' + ); + + return { packages, updatedDirs }; +} + +export function runCodegenJsCli(args: string[]): void { + const rootDir = path.resolve(parseFlagValue(args, '--root') ?? process.cwd()); + runCodegenJs({ + rootDir, + skipDpm: args.includes('--skip-dpm'), + }); +} diff --git a/src/daml/codegen/collapse-manifest.ts b/src/daml/codegen/collapse-manifest.ts new file mode 100644 index 0000000..3797693 --- /dev/null +++ b/src/daml/codegen/collapse-manifest.ts @@ -0,0 +1,39 @@ +/** + * Collapse TypeScript package manifests by dropping map files and collapsing + * `.js` / `.d.ts` pairs into extensionless entries. + */ + +/** Pure helper: collapse a list of package file paths. */ +export function collapseManifestLines(lines: readonly string[]): string[] { + const normalized = lines.map((line) => line.trim()).filter((line) => line.length > 0); + + if (normalized.length === 0) { + throw new Error('No files found for manifest generation'); + } + + const filesToKeep = new Set(); + const collapsedFiles = new Set(); + + for (const line of normalized) { + if (line.endsWith('.d.ts.map') || line.endsWith('.js.map')) { + continue; + } + filesToKeep.add(line); + } + + for (const file of filesToKeep) { + if (file.endsWith('.d.ts') || file.endsWith('.js')) { + collapsedFiles.add(file.replace(/\.(d\.ts|js)$/, '')); + } else { + collapsedFiles.add(file); + } + } + + return Array.from(collapsedFiles).sort(); +} + +/** CLI-style entry: read stdin lines and print the collapsed manifest. */ +export function collapseManifestFromStdin(input: string): string { + const lines = input.trim().split('\n'); + return collapseManifestLines(lines).join('\n'); +} diff --git a/src/daml/codegen/create-package-index.ts b/src/daml/codegen/create-package-index.ts new file mode 100644 index 0000000..e2fab04 --- /dev/null +++ b/src/daml/codegen/create-package-index.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs'; +import { writeGeneratedPackageIndex } from './generated-package-index'; + +export interface CreatePackageIndexOptions { + /** Absolute paths to generated package directories that should receive index.js / index.d.ts. */ + packageDirs: readonly string[]; +} + +/** Write standalone package index files for each existing generated package directory. */ +export function createPackageIndexes(options: CreatePackageIndexOptions): string[] { + const created: string[] = []; + for (const generatedDir of options.packageDirs) { + if (!fs.existsSync(generatedDir)) continue; + writeGeneratedPackageIndex(generatedDir); + created.push(generatedDir); + console.log(`Created package index files (index.js and index.d.ts) in ${generatedDir}`); + } + return created; +} diff --git a/src/daml/codegen/discover-codegen-packages.ts b/src/daml/codegen/discover-codegen-packages.ts new file mode 100644 index 0000000..746909b --- /dev/null +++ b/src/daml/codegen/discover-codegen-packages.ts @@ -0,0 +1,124 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as yaml from 'yaml'; +import { discoverManagedPackages, type PackageConfig } from '../packages'; +import { resolveContainedPath } from '../sync-splice-dars'; + +export interface CodegenPackageConfig extends PackageConfig { + /** Absolute path to generated/build/ (where `dpm codegen-js` runs). */ + absoluteBuildDir: string; + /** Absolute path to generated/js/-. */ + absoluteGeneratedJsDir: string; + /** Absolute path to generated/js/-/lib. */ + absoluteGeneratedLibDir: string; +} + +interface DamlYamlCodegen { + codegen?: { + js?: { + 'output-directory'?: string; + 'npm-scope'?: string; + }; + }; +} + +export interface DiscoverCodegenPackagesOptions { + rootDir: string; + /** Relative generated JS root (default `generated/js`). */ + generatedJsRoot?: string; +} + +/** Discover managed packages that declare `codegen.js` in daml.yaml. */ +export function discoverCodegenPackages( + options: DiscoverCodegenPackagesOptions +): CodegenPackageConfig[] { + const rootDir = path.resolve(options.rootDir); + const generatedJsRoot = options.generatedJsRoot ?? 'generated/js'; + const packages = discoverManagedPackages(rootDir); + const result: CodegenPackageConfig[] = []; + + for (const pkg of packages) { + // Prefer prepared build copy (output-directory already rewritten); fall back to source. + const buildDamlYaml = path.join(rootDir, pkg.buildDir, 'daml.yaml'); + const sourceDamlYaml = path.join(rootDir, pkg.sourceDir, 'daml.yaml'); + const damlYamlPath = fs.existsSync(buildDamlYaml) ? buildDamlYaml : sourceDamlYaml; + if (!fs.existsSync(damlYamlPath)) { + continue; + } + + const damlYaml = yaml.parse(fs.readFileSync(damlYamlPath, 'utf8')) as DamlYamlCodegen; + if (!damlYaml.codegen?.js) { + continue; + } + + const generatedJsDir = path.join(rootDir, generatedJsRoot, `${pkg.name}-${pkg.version}`); + resolveContainedPath( + rootDir, + path.relative(rootDir, generatedJsDir), + 'generated js package dir' + ); + + result.push({ + ...pkg, + absoluteBuildDir: path.join(rootDir, pkg.buildDir), + absoluteGeneratedJsDir: generatedJsDir, + absoluteGeneratedLibDir: path.join(generatedJsDir, 'lib'), + }); + } + + return result; +} + +/** + * Resolve published npm name for a codegen package. + * + * - `publishNameSuffix: null` → root package name + * - `publishNameSuffix: 'reports'` → `${root}-reports` + * - omitted → root package name when only one codegen package; otherwise `${root}-${key}` + */ +export function buildPublishedPackageName( + rootPackageName: string, + suffix: string | null | undefined, + fallbackKey?: string +): string { + if (suffix === null || suffix === undefined) { + if (suffix === null) return rootPackageName; + return fallbackKey ? `${rootPackageName}-${fallbackKey}` : rootPackageName; + } + return `${rootPackageName}-${suffix}`; +} + +export interface CodegenPublishSuffixMap { + /** Map daml package name or key → suffix (`null` = root package name). */ + [packageNameOrKey: string]: string | null; +} + +/** Read optional publish suffix map from package.json `cantonDevTools.codegenPublishSuffixes`. */ +export function readCodegenPublishSuffixes(rootDir: string): CodegenPublishSuffixMap { + const packageJsonPath = path.join(rootDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return {}; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + cantonDevTools?: { codegenPublishSuffixes?: CodegenPublishSuffixMap }; + }; + return packageJson.cantonDevTools?.codegenPublishSuffixes ?? {}; +} + +export function resolvePublishedPackageName(options: { + rootPackageName: string; + pkg: PackageConfig; + suffixes: CodegenPublishSuffixMap; + codegenPackageCount: number; +}): string { + const { rootPackageName, pkg, suffixes, codegenPackageCount } = options; + if (Object.prototype.hasOwnProperty.call(suffixes, pkg.name)) { + return buildPublishedPackageName(rootPackageName, suffixes[pkg.name]); + } + if (Object.prototype.hasOwnProperty.call(suffixes, pkg.key)) { + return buildPublishedPackageName(rootPackageName, suffixes[pkg.key]); + } + // Single codegen package (canton-assets / ocp): publish as the root package name. + if (codegenPackageCount === 1) { + return rootPackageName; + } + return buildPublishedPackageName(rootPackageName, undefined, pkg.key); +} diff --git a/src/daml/codegen/fix-splice-refs.ts b/src/daml/codegen/fix-splice-refs.ts new file mode 100644 index 0000000..69d4c63 --- /dev/null +++ b/src/daml/codegen/fix-splice-refs.ts @@ -0,0 +1,157 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + applyGeneratedImportRewrites, + collectGeneratedOutputFiles, + type GeneratedImportRewriteRule, +} from './generated-output-helpers'; + +export interface FixSpliceRefsOptions { + /** Directory to walk (typically `lib/` or a generated package `lib/`). */ + targetDir: string; + /** + * When true (default), rewrite `@fairmint/*` and `daml.js/*` imports to relative + * `__bundled__/` paths when that directory exists under `targetDir`. + * No-op when `__bundled__` is absent (pre-bundle generated trees). + */ + rewriteFairmintScopedImports?: boolean; +} + +function fixNestedNamespaceReferences(filePath: string, targetDir: string): boolean { + let content = fs.readFileSync(filePath, 'utf8'); + + const packageRegex = /var (pkg[a-f0-9]{64}) = require\('([^']+)'\);/g; + const packages: Map = new Map(); + + let match: RegExpExecArray | null; + while ((match = packageRegex.exec(content)) !== null) { + const pkgVar = match[1]; + const modulePath = match[2]; + if (!pkgVar || !modulePath) continue; + packages.set(pkgVar, modulePath); + } + + let modified = false; + for (const [pkgVar] of packages.entries()) { + const usageRegex = new RegExp( + `${pkgVar}\\.((?:[A-Z][A-Za-z0-9]*\\.)+)([A-Z][A-Za-z0-9_]*)`, + 'g' + ); + + const replacements = new Map(); + let usageMatch: RegExpExecArray | null; + while ((usageMatch = usageRegex.exec(content)) !== null) { + const fullMatch = usageMatch[0]; + const namespacePath = usageMatch[1] ?? ''; + const typeName = usageMatch[2]; + if (!typeName) continue; + + if (namespacePath.includes('Splice.') || /^[A-Z][A-Za-z0-9]*V\d+\./.test(namespacePath)) { + replacements.set(fullMatch, `${pkgVar}.${typeName}`); + } + } + + for (const [from, to] of replacements.entries()) { + const beforeReplace = content; + content = content.replace(new RegExp(from.replace(/\./g, '\\.'), 'g'), to); + + if (content !== beforeReplace) { + modified = true; + console.log(` Fixed ${from} -> ${to} in ${path.relative(targetDir, filePath)}`); + } + } + } + + if (modified) { + fs.writeFileSync(filePath, content); + } + return modified; +} + +function stripTrailingSemver(packageName: string): string { + return packageName.replace(/-\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]*)?$/, ''); +} + +function resolveBundledTarget(libRoot: string, importSpecifier: string): string | null { + const match = importSpecifier.match(/^(?:@fairmint\/|@?daml\.js\/)(.+)$/); + if (!match?.[1]) return null; + + const pkgName = match[1]; + const candidates = [pkgName, stripTrailingSemver(pkgName)]; + for (const candidate of candidates) { + const dir = path.join(libRoot, '__bundled__', candidate); + if (fs.existsSync(dir)) { + return dir; + } + } + return null; +} + +function buildFairmintScopedRewriteRules(libRoot: string): GeneratedImportRewriteRule[] { + const bundledRoot = path.join(libRoot, '__bundled__'); + if (!fs.existsSync(bundledRoot)) { + return []; + } + + const rules: GeneratedImportRewriteRule[] = []; + const seen = new Set(); + + for (const filePath of collectGeneratedOutputFiles(libRoot)) { + const source = fs.readFileSync(filePath, 'utf8'); + const importMatches = source.matchAll( + /(?:require\(|from )['"](@fairmint\/[^'"]+|@?daml\.js\/[^'"]+)['"]/g + ); + for (const importMatch of importMatches) { + const specifier = importMatch[1]; + if (!specifier || seen.has(specifier)) continue; + seen.add(specifier); + + const target = resolveBundledTarget(libRoot, specifier); + if (!target) continue; + + rules.push({ + importPaths: [specifier], + resolveTarget: () => target, + logLabel: 'bundled dependency', + }); + } + } + + return rules; +} + +/** + * Fix Splice / module-namespace references in generated JS bindings. + * + * Works for packages required via relative paths or `@fairmint/*` (npm-scope). + * Optionally rewrites remaining `@fairmint/*` / `daml.js/*` imports onto `__bundled__` + * siblings when present (assets merged-lib behavior). + */ +export function fixSpliceRefs(options: FixSpliceRefsOptions): number { + const targetDir = path.resolve(options.targetDir); + if (!fs.existsSync(targetDir)) { + throw new Error(`fix-splice-refs target not found: ${targetDir}`); + } + + console.log(`🔧 Fixing Splice API namespace references in ${targetDir}...`); + + let fixedCount = 0; + for (const filePath of collectGeneratedOutputFiles(targetDir)) { + if (fixNestedNamespaceReferences(filePath, targetDir)) { + fixedCount++; + } + } + + const rewriteFairmint = options.rewriteFairmintScopedImports ?? true; + if (rewriteFairmint) { + const rules = buildFairmintScopedRewriteRules(targetDir); + if (rules.length > 0) { + const rewritten = applyGeneratedImportRewrites(targetDir, rules); + fixedCount += rewritten; + console.log(` Rewrote ${rewritten} files with @fairmint/daml.js → __bundled__ paths`); + } + } + + console.log(`✅ Fixed ${fixedCount} files`); + return fixedCount; +} diff --git a/src/daml/codegen/generated-output-helpers.ts b/src/daml/codegen/generated-output-helpers.ts new file mode 100644 index 0000000..1a243c8 --- /dev/null +++ b/src/daml/codegen/generated-output-helpers.ts @@ -0,0 +1,192 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface GeneratedOutputWalkOptions { + ignoredDirs?: string[]; +} + +export interface GeneratedOutputTransformContext { + filePath: string; + isDts: boolean; +} + +export interface GeneratedImportRewriteRule { + importPaths: string[]; + resolveTarget: (filePath: string) => string; + logLabel?: string; +} + +export interface GeneratedOutputPairContents { + js: string; + dts: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function normalizeDir(dirPath: string): string { + return path.resolve(dirPath); +} + +function isWithinDir(dirPath: string, candidatePath: string): boolean { + const normalizedDir = normalizeDir(dirPath); + const normalizedCandidate = normalizeDir(candidatePath); + return ( + normalizedCandidate === normalizedDir || + normalizedCandidate.startsWith(`${normalizedDir}${path.sep}`) + ); +} + +function normalizeRelativeImport(fromFile: string, toTarget: string): string { + let relativePath = path.relative(path.dirname(fromFile), toTarget).replace(/\\/g, '/'); + if (relativePath === '') { + relativePath = '.'; + } + if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) { + relativePath = `./${relativePath}`; + } + return relativePath; +} + +function replaceImportPath( + source: string, + importPath: string, + relativePath: string, + isDts: boolean +): string { + const escapedImportPath = escapeRegExp(importPath); + if (isDts) { + return source.replace(new RegExp(`from '${escapedImportPath}';`, 'g'), `from '${relativePath}';`); + } + + return source.replace( + new RegExp(`require\\('${escapedImportPath}'\\)`, 'g'), + `require('${relativePath}')` + ); +} + +export function hasGeneratedOutputPair(dirPath: string, baseName: string): boolean { + const js = path.join(dirPath, `${baseName}.js`); + const dts = path.join(dirPath, `${baseName}.d.ts`); + return fs.existsSync(js) && fs.existsSync(dts); +} + +export function writeGeneratedOutputPair( + dirPath: string, + baseName: string, + contents: GeneratedOutputPairContents +): void { + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, `${baseName}.js`), contents.js); + fs.writeFileSync(path.join(dirPath, `${baseName}.d.ts`), contents.dts); +} + +export function collectGeneratedOutputFiles( + rootDir: string, + options: GeneratedOutputWalkOptions = {} +): string[] { + if (!fs.existsSync(rootDir)) { + return []; + } + + const ignoredDirs = (options.ignoredDirs ?? []).map((dirPath) => normalizeDir(dirPath)); + const pendingDirs = [rootDir]; + const files: string[] = []; + + while (pendingDirs.length > 0) { + const currentDir = pendingDirs.pop(); + if (!currentDir) { + continue; + } + + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + const entryPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + if (ignoredDirs.some((ignoredDir) => isWithinDir(ignoredDir, entryPath))) { + continue; + } + pendingDirs.push(entryPath); + continue; + } + + if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.d.ts'))) { + files.push(entryPath); + } + } + } + + return files; +} + +export function findGeneratedOutputFilesContainingAny( + rootDir: string, + needles: readonly string[], + options: GeneratedOutputWalkOptions = {} +): string[] { + return collectGeneratedOutputFiles(rootDir, options).filter((filePath) => { + const source = fs.readFileSync(filePath, 'utf8'); + return needles.some((needle) => source.includes(needle)); + }); +} + +export function rewriteGeneratedOutputFiles( + rootDir: string, + transform: (source: string, context: GeneratedOutputTransformContext) => string, + options: GeneratedOutputWalkOptions = {} +): number { + let rewrittenCount = 0; + + for (const filePath of collectGeneratedOutputFiles(rootDir, options)) { + const source = fs.readFileSync(filePath, 'utf8'); + const next = transform(source, { filePath, isDts: filePath.endsWith('.d.ts') }); + + if (next !== source) { + fs.writeFileSync(filePath, next); + rewrittenCount++; + } + } + + return rewrittenCount; +} + +export function applyGeneratedImportRewrites( + rootDir: string, + rules: GeneratedImportRewriteRule[], + options: GeneratedOutputWalkOptions = {} +): number { + return rewriteGeneratedOutputFiles( + rootDir, + (source, context) => { + let next = source; + + for (const rule of rules) { + if (!rule.importPaths.some((importPath) => next.includes(importPath))) { + continue; + } + + const relativePath = normalizeRelativeImport( + context.filePath, + rule.resolveTarget(context.filePath) + ); + const updated = rule.importPaths.reduce( + (currentSource, importPath) => + replaceImportPath(currentSource, importPath, relativePath, context.isDts), + next + ); + + if (updated !== next && rule.logLabel) { + console.log( + ` Updating ${path.relative(rootDir, context.filePath)} with ${rule.logLabel} path: ${relativePath}` + ); + } + + next = updated; + } + + return next; + }, + options + ); +} diff --git a/src/daml/codegen/generated-package-index.ts b/src/daml/codegen/generated-package-index.ts new file mode 100644 index 0000000..888d08d --- /dev/null +++ b/src/daml/codegen/generated-package-index.ts @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export const GENERATED_PACKAGE_INDEX_JS = `"use strict"; + +// Re-export everything from the lib directory +const lib = require('./lib/index.js'); + +// Export all properties from lib +Object.keys(lib).forEach(key => { + exports[key] = lib[key]; +}); + +// Also export the lib object itself for backward compatibility +exports.lib = lib; +`; + +export const GENERATED_PACKAGE_INDEX_DTS = `// Re-export everything from the lib directory +export * from './lib/index'; + +// Also export the lib object itself for backward compatibility +import * as lib from './lib/index'; +export { lib }; +`; + +export function writeGeneratedPackageIndex(generatedDir: string): void { + fs.writeFileSync(path.join(generatedDir, 'index.js'), GENERATED_PACKAGE_INDEX_JS); + fs.writeFileSync(path.join(generatedDir, 'index.d.ts'), GENERATED_PACKAGE_INDEX_DTS); +} diff --git a/src/daml/codegen/index.ts b/src/daml/codegen/index.ts new file mode 100644 index 0000000..25a71ca --- /dev/null +++ b/src/daml/codegen/index.ts @@ -0,0 +1,12 @@ +/** Generic DAML → npm JS bindings helpers (Phase 1). */ + +export * from './generated-output-helpers'; +export * from './generated-package-index'; +export * from './create-package-index'; +export * from './collapse-manifest'; +export * from './install-generated-deps'; +export * from './update-generated-package'; +export * from './fix-splice-refs'; +export * from './verify-package-imports'; +export * from './discover-codegen-packages'; +export * from './codegen-js'; diff --git a/src/daml/codegen/install-generated-deps.ts b/src/daml/codegen/install-generated-deps.ts new file mode 100644 index 0000000..e671c79 --- /dev/null +++ b/src/daml/codegen/install-generated-deps.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getErrorMessage, type PackageJson } from '../types'; + +export interface InstallGeneratedDepsOptions { + /** Absolute path to `generated/js` (or equivalent). */ + generatedJsDir: string; + /** npm argv after `npm` (default: `install --no-package-lock --silent`). */ + npmInstallArgs?: string[]; +} + +/** Install dependencies for each generated package under `generatedJsDir` that declares any. */ +export function installGeneratedDeps(options: InstallGeneratedDepsOptions): void { + const generatedJsDir = path.resolve(options.generatedJsDir); + if (!fs.existsSync(generatedJsDir)) { + throw new Error(`Generated JS directory not found: ${generatedJsDir}`); + } + + const npmInstallArgs = options.npmInstallArgs ?? ['install', '--no-package-lock', '--silent']; + + const packages = fs + .readdirSync(generatedJsDir) + .filter((dir) => fs.existsSync(path.join(generatedJsDir, dir, 'package.json'))) + .map((dir) => path.join(generatedJsDir, dir)); + + console.log( + 'Found packages:', + packages.map((packageDir) => path.basename(packageDir)) + ); + + for (const packageDir of packages) { + const packageJsonPath = path.join(packageDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson; + + const deps = packageJson.dependencies ? Object.keys(packageJson.dependencies) : []; + if (deps.length > 0) { + console.log(`Installing dependencies for ${path.basename(packageDir)}...`); + try { + execFileSync('npm', npmInstallArgs, { + cwd: packageDir, + stdio: 'inherit', + }); + console.log( + `✓ Dependencies installed for ${path.basename(packageDir)} (${deps.length} deps)` + ); + } catch (error) { + throw new Error( + `Failed to install dependencies for ${path.basename(packageDir)}: ${getErrorMessage(error)}` + ); + } + } else { + console.log(`Skipping ${path.basename(packageDir)} (no dependencies)`); + } + } + + console.log('All dependencies installed successfully!'); +} diff --git a/src/daml/codegen/update-generated-package.ts b/src/daml/codegen/update-generated-package.ts new file mode 100644 index 0000000..c5873da --- /dev/null +++ b/src/daml/codegen/update-generated-package.ts @@ -0,0 +1,100 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { PackageJson } from '../types'; +import { writeGeneratedPackageIndex } from './generated-package-index'; + +export interface GeneratedPackageUpdateTarget { + /** Absolute path to the generated package directory (contains package.json). */ + dir: string; + /** Published npm package name to write into the generated package.json. */ + publishedPackageName: string; +} + +export interface UpdateGeneratedPackagesOptions { + /** Root package name (used only for logging / validation). */ + rootPackageName: string; + /** Version to stamp onto every generated package.json. */ + rootPackageVersion: string; + /** Optional peerDependencies copied onto each generated package. */ + peerDependencies?: Record; + /** Packages to update. */ + packages: readonly GeneratedPackageUpdateTarget[]; + /** When true (default), also write index.js / index.d.ts. */ + writeIndex?: boolean; +} + +/** Update generated package.json name/version/peers and optionally write package indexes. */ +export function updateGeneratedPackages(options: UpdateGeneratedPackagesOptions): string[] { + const updated: string[] = []; + const writeIndex = options.writeIndex ?? true; + + for (const { dir, publishedPackageName } of options.packages) { + const packageJsonPath = path.join(dir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) continue; + + const generatedPackage = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson; + + generatedPackage.version = options.rootPackageVersion; + generatedPackage.name = publishedPackageName; + delete generatedPackage.private; + + generatedPackage.publishConfig ??= { access: 'public' }; + + if (generatedPackage['peer-dependencies']) { + generatedPackage.peerDependencies = { + ...(generatedPackage.peerDependencies ?? {}), + ...generatedPackage['peer-dependencies'], + }; + delete generatedPackage['peer-dependencies']; + } + + if (options.peerDependencies) { + generatedPackage.peerDependencies = { ...options.peerDependencies }; + } + + fs.writeFileSync(packageJsonPath, `${JSON.stringify(generatedPackage, null, 4)}\n`); + + if (writeIndex) { + writeGeneratedPackageIndex(dir); + } + + updated.push(dir); + console.log( + `Updated generated package.json for ${options.rootPackageName}: ` + + `name=${generatedPackage.name}, version=${generatedPackage.version}` + ); + if (writeIndex) { + console.log(`Created package index files (index.js and index.d.ts) in ${dir}`); + } + } + + return updated; +} + +export interface UpdateGeneratedPackagesFromRootOptions { + rootDir: string; + packages: readonly GeneratedPackageUpdateTarget[]; + writeIndex?: boolean; +} + +/** Convenience: read root package.json then update generated packages. */ +export function updateGeneratedPackagesFromRoot( + options: UpdateGeneratedPackagesFromRootOptions +): string[] { + const rootPackagePath = path.join(options.rootDir, 'package.json'); + const rootPackage = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')) as PackageJson; + if (!rootPackage.name) { + throw new Error(`Root package.json missing package name: ${rootPackagePath}`); + } + if (!rootPackage.version) { + throw new Error(`Root package.json missing version: ${rootPackagePath}`); + } + + return updateGeneratedPackages({ + rootPackageName: rootPackage.name, + rootPackageVersion: rootPackage.version, + peerDependencies: rootPackage.peerDependencies, + packages: options.packages, + writeIndex: options.writeIndex, + }); +} diff --git a/src/daml/codegen/verify-package-imports.ts b/src/daml/codegen/verify-package-imports.ts new file mode 100644 index 0000000..c2cc770 --- /dev/null +++ b/src/daml/codegen/verify-package-imports.ts @@ -0,0 +1,103 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getErrorMessage } from '../types'; +import { collectGeneratedOutputFiles } from './generated-output-helpers'; + +/** Default unresolved-import patterns (assets / daml.js + @fairmint npm-scope). */ +export const DEFAULT_UNRESOLVED_IMPORT_PATTERNS: readonly RegExp[] = [ + /require\(['"]@?daml\.js\/[^'"]+['"]\)/g, + /from ['"]@?daml\.js\/[^'"]+['"]/g, + /require\(['"]@fairmint\/(?:splice-|ghc-stdlib-|daml-stdlib-|daml-prim-)[^'"]+['"]\)/g, + /from ['"]@fairmint\/(?:splice-|ghc-stdlib-|daml-stdlib-|daml-prim-)[^'"]+['"]/g, +]; + +/** daml.js-only patterns (repos that do not use npm-scope: fairmint). */ +export const DAML_JS_UNRESOLVED_IMPORT_PATTERNS: readonly RegExp[] = [ + /require\(['"]@?daml\.js\/[^'"]+['"]\)/g, + /from ['"]@?daml\.js\/[^'"]+['"]/g, +]; + +export interface UnresolvedImportIssue { + file: string; + matches: string[]; +} + +export interface VerifyPackageImportsOptions { + /** Directory to scan (typically repo `lib/`). */ + libDir: string; + /** Override forbidden unresolved import patterns. */ + unresolvedPatterns?: readonly RegExp[]; + /** When false, do not throw / exit — just return issues. Default true for CLI. */ + throwOnIssues?: boolean; +} + +export function findUnresolvedPackageImports( + options: VerifyPackageImportsOptions +): UnresolvedImportIssue[] { + const libDir = path.resolve(options.libDir); + const patterns = options.unresolvedPatterns ?? DEFAULT_UNRESOLVED_IMPORT_PATTERNS; + const issues: UnresolvedImportIssue[] = []; + + for (const filePath of collectGeneratedOutputFiles(libDir)) { + const content = fs.readFileSync(filePath, 'utf8'); + const matches: string[] = []; + + for (const pattern of patterns) { + // Clone so lastIndex does not leak across files for global regexes. + const cloned = new RegExp(pattern.source, pattern.flags); + const found = content.match(cloned); + if (found) { + matches.push(...found); + } + } + + if (matches.length > 0) { + issues.push({ + file: path.relative(libDir, filePath), + matches: [...new Set(matches)], + }); + } + } + + return issues; +} + +/** Verify published lib has no unresolved daml.js / @fairmint codegen imports. */ +export function verifyPackageImports(options: VerifyPackageImportsOptions): UnresolvedImportIssue[] { + const libDir = path.resolve(options.libDir); + console.log(`🔍 Checking for unresolved daml.js/ imports in ${libDir}...\n`); + + if (!fs.existsSync(libDir)) { + throw new Error(`lib directory not found: ${libDir}. Run codegen first.`); + } + + try { + const issues = findUnresolvedPackageImports(options); + + if (issues.length > 0) { + console.error('❌ Found unresolved daml.js/ or @fairmint codegen imports:\n'); + for (const issue of issues) { + console.error(` ${issue.file}:`); + for (const match of issue.matches) { + console.error(` - ${match}`); + } + console.error(''); + } + console.error( + 'These imports should have been replaced with relative paths by bundle-dependencies.' + ); + if (options.throwOnIssues !== false) { + throw new Error('Unresolved codegen imports remain in published lib'); + } + return issues; + } + + console.log('✅ No unresolved daml.js/ imports found. Package is ready for publish.'); + return issues; + } catch (error) { + if (error instanceof Error && error.message.startsWith('Unresolved codegen')) { + throw error; + } + throw new Error(`Error checking imports: ${getErrorMessage(error)}`); + } +} diff --git a/src/daml/index.ts b/src/daml/index.ts index 0e85979..0157cc9 100644 --- a/src/daml/index.ts +++ b/src/daml/index.ts @@ -10,3 +10,4 @@ export * from './backup-dar'; export * from './check-dar-version-policy'; export * from './check-upgrade-compatibility'; export * from './sync-splice-dars'; +export * from './codegen'; diff --git a/src/daml/types.ts b/src/daml/types.ts index ddb4495..3b48c6e 100644 --- a/src/daml/types.ts +++ b/src/daml/types.ts @@ -18,3 +18,17 @@ export type ContractNetwork = 'mainnet' | 'devnet'; export function isContractNetwork(value: string): value is ContractNetwork { return value === 'mainnet' || value === 'devnet'; } + +/** Minimal package.json shape used by codegen / release helpers. */ +export interface PackageJson { + name?: string; + version?: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + 'peer-dependencies'?: Record; + publishConfig?: { access?: string }; + repository?: string | { type?: string; url?: string }; + [key: string]: unknown; +} diff --git a/src/prepare-release.ts b/src/prepare-release.ts new file mode 100644 index 0000000..e8def1f --- /dev/null +++ b/src/prepare-release.ts @@ -0,0 +1,311 @@ +/** + * Prepare Release Script + * + * Selects the next version (floor-style) and prepends CHANGELOG.md. + * + * Version selection (ocp-canton-sdk / ui-style floor): + * - If package.json version is ahead of npm latest (or npm returns 404) and is free, + * publish that version exactly. + * - Otherwise patch-increment from the higher of npm latest / package.json, skipping + * versions that already exist on npm or as git tags. + * + * Rewrites package.json version in the CI workspace only — does not commit it back. + */ + +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseFlagValue } from './daml/packages'; +import type { PackageJson } from './daml/types'; + +interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +export interface PrepareReleaseOptions { + rootDir: string; + /** + * GitHub `owner/repo` used in changelog previous-version links. + * Defaults to `--changelog-repo`, then package.json repository.url, then the + * Fairmint package name heuristic. + */ + changelogRepo?: string; +} + +/** Check if a git tag exists */ +function tagExists(rootDir: string, tag: string): boolean { + try { + execSync(`git rev-parse "refs/tags/${tag}"`, { cwd: rootDir, stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** Encode a scoped package name for the npm registry HTTP API. */ +function encodePackageNameForRegistry(packageName: string): string { + return packageName.replace('/', '%2f'); +} + +/** + * Read published versions from the public registry HTTP API. + * + * Prefer this over `npm view` when a classic auth token in npmrc can 404 public + * packages the token cannot read (npm reports that as 404, not 403). + */ +function getNpmMetadataFromRegistry(packageName: string): { + latest: string | null; + versions: Set; +} | null { + try { + const encodedName = encodePackageNameForRegistry(packageName); + const result = execSync(`curl -fsS "https://registry.npmjs.org/${encodedName}"`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const metadata = JSON.parse(result) as { + 'dist-tags'?: { latest?: string }; + versions?: Record; + }; + const versions = new Set(Object.keys(metadata.versions ?? {})); + const latest = metadata['dist-tags']?.latest ?? null; + return { latest, versions }; + } catch { + return null; + } +} + +/** Get all published versions from NPM registry */ +function getAllNpmVersions(packageName: string): Set { + try { + const result = execSync(`npm view "${packageName}" versions --json`, { + encoding: 'utf8', + }).trim(); + const versions = JSON.parse(result) as string | string[]; + if (Array.isArray(versions)) { + return new Set(versions); + } + return new Set([versions]); + } catch { + return new Set(); + } +} + +/** Get the latest version from NPM registry */ +function getLatestNpmVersion(packageName: string): string | null { + try { + const result = execSync(`npm view "${packageName}" version`, { encoding: 'utf8' }).trim(); + return result || null; + } catch { + return null; + } +} + +/** Parse version string into components */ +function parseVersion(version: string): ParsedVersion | null { + const parts = version.split('.').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + return null; + } + if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { + return null; + } + return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! }; +} + +/** Compare two parsed semantic versions. */ +function compareVersions(left: ParsedVersion, right: ParsedVersion): number { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; +} + +/** Find the next available version by incrementing patch until free on tags and npm */ +function findNextAvailableVersion( + isVersionTaken: (version: string) => boolean, + major: number, + minor: number, + startPatch: number +): string { + let patch = startPatch; + let version: string; + + do { + patch++; + version = `${major}.${minor}.${patch}`; + } while (isVersionTaken(version)); + + return version; +} + +/** + * Select the version to publish. + * + * A manifest version newer than the latest NPM version (including first publish when npm is + * missing) is an explicit release boundary, so publish it unchanged when it is available. + * Once that version exists, normal patch increments resume from the highest baseline. + */ +export function selectReleaseVersion( + manifestVersion: string, + latestNpmVersion: string | null, + isVersionTaken: (version: string) => boolean +): string { + const manifestParsed = parseVersion(manifestVersion); + if (!manifestParsed) { + throw new Error('Invalid version format in package.json. Expected format: x.y.z'); + } + + const npmParsed = latestNpmVersion ? parseVersion(latestNpmVersion) : null; + const manifestAheadOfNpm = !npmParsed || compareVersions(manifestParsed, npmParsed) > 0; + + if (manifestAheadOfNpm && !isVersionTaken(manifestVersion)) { + return manifestVersion; + } + + const baseline = + npmParsed && compareVersions(npmParsed, manifestParsed) > 0 ? npmParsed : manifestParsed; + return findNextAvailableVersion(isVersionTaken, baseline.major, baseline.minor, baseline.patch); +} + +/** Parse `owner/repo` from a package.json repository field or git URL. */ +export function parseChangelogRepo( + repository: PackageJson['repository'] | undefined +): string | undefined { + if (!repository) return undefined; + const url = typeof repository === 'string' ? repository : repository.url; + if (!url) return undefined; + + const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?/i); + return match?.[1]; +} + +export function resolveChangelogRepo( + packageJson: PackageJson, + explicit?: string +): string | undefined { + if (explicit) return explicit; + return parseChangelogRepo(packageJson.repository); +} + +/** + * Prepare release by selecting version and generating changelog. + * Safe for local testing (no git tag / push operations). + */ +export function prepareRelease(options: PrepareReleaseOptions): string { + const rootDir = path.resolve(options.rootDir); + const packageJsonPath = path.join(rootDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson; + + if (!packageJson.name || !packageJson.version) { + throw new Error(`package.json at ${packageJsonPath} must include name and version`); + } + + const packageName = packageJson.name; + const currentVersion = packageJson.version; + console.log(`Package: ${packageName}`); + console.log(`Current version in package.json: ${currentVersion}`); + + console.log('Fetching published versions from NPM...'); + let npmVersions = getAllNpmVersions(packageName); + let latestNpmVersion = getLatestNpmVersion(packageName); + + if (!latestNpmVersion && npmVersions.size === 0) { + const registryMetadata = getNpmMetadataFromRegistry(packageName); + if (registryMetadata) { + console.log('npm view returned no versions; using public registry HTTP metadata instead'); + npmVersions = registryMetadata.versions; + latestNpmVersion = registryMetadata.latest; + } + } + + if (latestNpmVersion) { + console.log(`Latest version on NPM: ${latestNpmVersion}`); + console.log(`Total published versions: ${npmVersions.size}`); + } else { + console.log('No version found on NPM (new package or registry unavailable)'); + } + + const isVersionTaken = (version: string): boolean => + npmVersions.has(version) || tagExists(rootDir, `v${version}`); + const newVersion = selectReleaseVersion(currentVersion, latestNpmVersion, isVersionTaken); + + console.log(`New version: ${newVersion}`); + + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + + console.log('✅ Updated package.json with new version'); + + let commits: string; + let lastTag: string | null = null; + try { + lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { + cwd: rootDir, + encoding: 'utf8', + }).trim(); + console.log(`Last tag: ${lastTag}`); + commits = execSync(`git log --oneline --format="%s" ${lastTag}..HEAD`, { + cwd: rootDir, + encoding: 'utf8', + }).trim(); + } catch { + console.log('No previous tag found, using recent commit history'); + commits = execSync('git log --oneline --format="%s" -n 20', { + cwd: rootDir, + encoding: 'utf8', + }).trim(); + } + + if (!commits) { + console.log('No commits found for changelog, using placeholder'); + commits = 'Initial release'; + } + + const commitLines = commits.split('\n').map((commit: string): string => `- ${commit}`); + const changelog = commitLines.join('\n'); + + console.log('\n📋 Generated changelog:'); + console.log('='.repeat(50)); + console.log(changelog); + console.log('='.repeat(50)); + + const tagMessage = `Release v${newVersion}\n\nChanges:\n${changelog}`; + + console.log('\n🏷️ Tag message preview:'); + console.log('='.repeat(50)); + console.log(tagMessage); + console.log('='.repeat(50)); + + const changelogPath = path.join(rootDir, 'CHANGELOG.md'); + const changelogRepo = resolveChangelogRepo(packageJson, options.changelogRepo); + const previousVersionLink = + lastTag && changelogRepo + ? `\n[Previous version: ${lastTag}](https://github.com/${changelogRepo}/releases/tag/${lastTag})` + : ''; + + const changelogContent = `# Changelog for v${newVersion}\n\n${changelog}${previousVersionLink}\n\n`; + + if (fs.existsSync(changelogPath)) { + const existingChangelog = fs.readFileSync(changelogPath, 'utf8'); + fs.writeFileSync(changelogPath, changelogContent + existingChangelog); + } else { + fs.writeFileSync(changelogPath, changelogContent); + } + + console.log(`\n✅ Saved changelog to CHANGELOG.md`); + console.log(`\n🎯 Ready for release! CI will publish and tag v${newVersion}.`); + return newVersion; +} + +export function runPrepareReleaseCli(args: string[]): void { + const rootDir = path.resolve(parseFlagValue(args, '--root') ?? process.cwd()); + const changelogRepo = parseFlagValue(args, '--changelog-repo'); + try { + prepareRelease({ rootDir, changelogRepo }); + } catch (error) { + console.error('❌ Error preparing release:', error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/test/unit/daml/codegen.test.ts b/test/unit/daml/codegen.test.ts new file mode 100644 index 0000000..3346315 --- /dev/null +++ b/test/unit/daml/codegen.test.ts @@ -0,0 +1,226 @@ +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + collapseManifestLines, + createPackageIndexes, + GENERATED_PACKAGE_INDEX_DTS, + GENERATED_PACKAGE_INDEX_JS, + hasGeneratedOutputPair, + writeGeneratedOutputPair, + rewriteGeneratedOutputFiles, + applyGeneratedImportRewrites, + findUnresolvedPackageImports, + DEFAULT_UNRESOLVED_IMPORT_PATTERNS, + buildPublishedPackageName, + resolvePublishedPackageName, + fixSpliceRefs, + updateGeneratedPackages, +} from '../../../src/daml/codegen'; +import { parseChangelogRepo, selectReleaseVersion } from '../../../src/prepare-release'; + +describe('collapseManifestLines', (): void => { + it('drops map files and collapses js/d.ts pairs', (): void => { + expect( + collapseManifestLines([ + 'lib/index.js', + 'lib/index.d.ts', + 'lib/index.js.map', + 'lib/index.d.ts.map', + 'README.md', + ]) + ).toEqual(['README.md', 'lib/index']); + }); + + it('throws when no files remain', (): void => { + expect(() => collapseManifestLines([])).toThrow(/No files found/); + }); +}); + +describe('generated package index helpers', (): void => { + it('writes index.js and index.d.ts', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'codegen-index-')); + try { + createPackageIndexes({ packageDirs: [dir] }); + expect(readFileSync(join(dir, 'index.js'), 'utf8')).toBe(GENERATED_PACKAGE_INDEX_JS); + expect(readFileSync(join(dir, 'index.d.ts'), 'utf8')).toBe(GENERATED_PACKAGE_INDEX_DTS); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('generated output helpers', (): void => { + it('writes and detects output pairs, then rewrites imports', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'codegen-out-')); + try { + writeGeneratedOutputPair(dir, 'mod', { + js: "const x = require('daml.js/foo');\n", + dts: "export * from 'daml.js/foo';\n", + }); + expect(hasGeneratedOutputPair(dir, 'mod')).toBe(true); + + const rewritten = rewriteGeneratedOutputFiles(dir, (source, ctx) => + source.replace(/daml\.js\/foo/g, ctx.isDts ? './rel' : './rel') + ); + expect(rewritten).toBe(2); + expect(readFileSync(join(dir, 'mod.js'), 'utf8')).toContain("./rel"); + + writeGeneratedOutputPair(dir, 'other', { + js: "require('@fairmint/splice-api-token-metadata-v1-1.0.0');\n", + dts: "from '@fairmint/splice-api-token-metadata-v1-1.0.0';\n", + }); + const target = join(dir, '__bundled__', 'splice-api-token-metadata-v1'); + mkdirSync(target, { recursive: true }); + applyGeneratedImportRewrites(dir, [ + { + importPaths: ['@fairmint/splice-api-token-metadata-v1-1.0.0'], + resolveTarget: () => target, + }, + ]); + expect(readFileSync(join(dir, 'other.js'), 'utf8')).toContain('__bundled__'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('updateGeneratedPackages', (): void => { + it('stamps name/version and normalizes peer-dependencies', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'codegen-update-')); + try { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ + name: '@fairmint/old', + version: '9.9.9', + private: true, + 'peer-dependencies': { '@daml/types': '3.5.2' }, + }) + ); + updateGeneratedPackages({ + rootPackageName: '@fairmint/wrapped-assets-daml-js', + rootPackageVersion: '0.0.1', + peerDependencies: { '@daml/types': '3.5.2', '@daml/ledger': '2.10.4' }, + packages: [{ dir, publishedPackageName: '@fairmint/wrapped-assets-daml-js' }], + }); + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { + name: string; + version: string; + private?: boolean; + peerDependencies?: Record; + 'peer-dependencies'?: unknown; + }; + expect(pkg.name).toBe('@fairmint/wrapped-assets-daml-js'); + expect(pkg.version).toBe('0.0.1'); + expect(pkg.private).toBeUndefined(); + expect(pkg['peer-dependencies']).toBeUndefined(); + expect(pkg.peerDependencies).toEqual({ + '@daml/types': '3.5.2', + '@daml/ledger': '2.10.4', + }); + expect(existsSync(join(dir, 'index.js'))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('publish name helpers', (): void => { + it('builds suffix names and resolves single-package root name', (): void => { + expect(buildPublishedPackageName('@fairmint/daml-js', null)).toBe('@fairmint/daml-js'); + expect(buildPublishedPackageName('@fairmint/daml-js', 'reports')).toBe( + '@fairmint/daml-js-reports' + ); + expect( + resolvePublishedPackageName({ + rootPackageName: '@fairmint/wrapped-assets-daml-js', + pkg: { + key: 'wrappedassets-v01', + name: 'WrappedAssets-v01', + darName: 'WrappedAssets-v01', + version: '0.0.1', + sourceDir: 'WrappedAssets-v01', + buildDir: 'generated/build/WrappedAssets-v01', + }, + suffixes: {}, + codegenPackageCount: 1, + }) + ).toBe('@fairmint/wrapped-assets-daml-js'); + }); +}); + +describe('fixSpliceRefs', (): void => { + it('collapses nested Splice namespaces and rewrites @fairmint imports to __bundled__', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'fix-splice-')); + try { + const bundled = join(dir, '__bundled__', 'splice-api-token-metadata-v1'); + mkdirSync(bundled, { recursive: true }); + writeFileSync( + join(dir, 'Holding.js'), + [ + "var pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567 = require('@fairmint/splice-api-token-metadata-v1-1.0.0');", + 'exports.x = pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567.Splice.Api.Token.MetadataV1.Metadata;', + '', + ].join('\n') + ); + writeFileSync(join(dir, 'Holding.d.ts'), 'export {};\n'); + + fixSpliceRefs({ targetDir: dir }); + + const js = readFileSync(join(dir, 'Holding.js'), 'utf8'); + expect(js).toContain( + 'pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567.Metadata' + ); + expect(js).not.toContain('Splice.Api.Token.MetadataV1.Metadata'); + expect(js).toContain('__bundled__/splice-api-token-metadata-v1'); + expect(js).not.toContain('@fairmint/splice-api-token-metadata-v1-1.0.0'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('verifyPackageImports', (): void => { + it('flags unresolved daml.js and @fairmint codegen imports by default', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'verify-imports-')); + try { + writeFileSync( + join(dir, 'bad.js'), + "require('@fairmint/splice-api-token-holding-v1-1.0.0');\nrequire('daml.js/ghc-stdlib-DA-Internal-Template-1.0.0');\n" + ); + const issues = findUnresolvedPackageImports({ + libDir: dir, + unresolvedPatterns: DEFAULT_UNRESOLVED_IMPORT_PATTERNS, + }); + expect(issues).toHaveLength(1); + expect(issues[0]?.matches.length).toBeGreaterThanOrEqual(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('selectReleaseVersion / changelog repo', (): void => { + const withTakenVersions = (...versions: string[]) => { + const taken = new Set(versions); + return (version: string): boolean => taken.has(version); + }; + + it('keeps floor version on first publish', (): void => { + expect(selectReleaseVersion('0.0.1', '0.0.0', withTakenVersions('0.0.0'))).toBe('0.0.1'); + }); + + it('parses changelog repo from package.json repository url', (): void => { + expect( + parseChangelogRepo({ type: 'git', url: 'git+https://github.com/Fairmint/canton-assets.git' }) + ).toBe('Fairmint/canton-assets'); + }); +}); diff --git a/test/unit/scripts/canton-dev-tools.test.ts b/test/unit/scripts/canton-dev-tools.test.ts index 8b6b8dc..f8df7bb 100644 --- a/test/unit/scripts/canton-dev-tools.test.ts +++ b/test/unit/scripts/canton-dev-tools.test.ts @@ -190,6 +190,9 @@ describe('canton-dev-tools DAML command dispatch', (): void => { expect(help).toContain('prepare-build'); expect(help).toContain('sync-splice-dars'); expect(help).toContain('install-dpm-sdks'); + expect(help).toContain('codegen-js'); + expect(help).toContain('prepare-release'); + expect(help).toContain('collapse-manifest'); }); it('dispatches prepare-build to dist/cli.js', (): void => { @@ -216,6 +219,16 @@ describe('canton-dev-tools DAML command dispatch', (): void => { encoding: 'utf8', }); expect(output).toBe('prepare-build --root /tmp'); + + const codegen = execFileSync(localnetBin, ['codegen-js', '--root', '/tmp'], { + encoding: 'utf8', + }); + expect(codegen).toBe('codegen-js --root /tmp'); + + const release = execFileSync(localnetBin, ['prepare-release', '--changelog-repo', 'Fairmint/canton-assets'], { + encoding: 'utf8', + }); + expect(release).toBe('prepare-release --changelog-repo Fairmint/canton-assets'); } finally { rmSync(packageRoot, { recursive: true, force: true }); } From bf426ae5b7436e7c22f653ad06db6c56139a2c36 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 00:08:48 +0000 Subject: [PATCH 02/12] test: use 64-char package id in fixSpliceRefs fixture Co-authored-by: HardlyDifficult --- test/unit/daml/codegen.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/daml/codegen.test.ts b/test/unit/daml/codegen.test.ts index 3346315..3b996b8 100644 --- a/test/unit/daml/codegen.test.ts +++ b/test/unit/daml/codegen.test.ts @@ -166,8 +166,8 @@ describe('fixSpliceRefs', (): void => { writeFileSync( join(dir, 'Holding.js'), [ - "var pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567 = require('@fairmint/splice-api-token-metadata-v1-1.0.0');", - 'exports.x = pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567.Splice.Api.Token.MetadataV1.Metadata;', + "var pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 = require('@fairmint/splice-api-token-metadata-v1-1.0.0');", + 'exports.x = pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.Splice.Api.Token.MetadataV1.Metadata;', '', ].join('\n') ); @@ -177,7 +177,7 @@ describe('fixSpliceRefs', (): void => { const js = readFileSync(join(dir, 'Holding.js'), 'utf8'); expect(js).toContain( - 'pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef01234567.Metadata' + 'pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.Metadata' ); expect(js).not.toContain('Splice.Api.Token.MetadataV1.Metadata'); expect(js).toContain('__bundled__/splice-api-token-metadata-v1'); From 897cbf1eb65d790d87508c392c2c27482f53267b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 00:11:15 +0000 Subject: [PATCH 03/12] fix: run prepare build so github installs ship dist/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git branch consumers (npm install github:…#branch) need a prepare lifecycle so dist/cli.js exists; prepack alone only runs for npm pack/publish. Co-authored-by: hardlydiff --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0750c7a..f6a482f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "localnet:cip56-transfer": "CANTON_CIP56_REQUIRE_LOCALNET=1 tsx scripts/run-cip56-transfer-smoke.ts", "pack:check": "npm run check:package-artifacts", "prepack": "npm run clean && npm run build", + "prepare": "npm run build", "prepare-release": "tsx scripts/prepare-release.ts", "prepublishOnly": "npm run prepack", "test": "npm run -s typecheck && jest", From 47c2205922f3515a75a034b8c0137509ef74aaf2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 12:16:52 +0000 Subject: [PATCH 04/12] feat: extract config-driven bundle-dependencies + create-root-index (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add general-purpose DAML→JS bundling engines driven by daml-js-bundle.json with stdlib/Splice presets only (no product package names). Expose CLI commands and library exports; cover with unit fixtures. Co-authored-by: hardlydiff --- README.md | 143 ++++- bin/canton-dev-tools | 7 +- src/cli.ts | 34 +- src/daml/codegen/bundle-dependencies.ts | 374 ++++++++++++ src/daml/codegen/bundle-fs.ts | 115 ++++ src/daml/codegen/bundle-presets.ts | 666 +++++++++++++++++++++ src/daml/codegen/codegen-js.ts | 7 +- src/daml/codegen/create-root-index.ts | 274 +++++++++ src/daml/codegen/daml-js-bundle-config.ts | 381 ++++++++++++ src/daml/codegen/index.ts | 7 +- test/unit/daml/bundle-dependencies.test.ts | 326 ++++++++++ 11 files changed, 2310 insertions(+), 24 deletions(-) create mode 100644 src/daml/codegen/bundle-dependencies.ts create mode 100644 src/daml/codegen/bundle-fs.ts create mode 100644 src/daml/codegen/bundle-presets.ts create mode 100644 src/daml/codegen/create-root-index.ts create mode 100644 src/daml/codegen/daml-js-bundle-config.ts create mode 100644 test/unit/daml/bundle-dependencies.test.ts diff --git a/README.md b/README.md index 4c26803..36b2ed6 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ npx canton-dev-tools check-dar-version-policy --extra-policy-paths scripts/codeg npx canton-dev-tools check-upgrade-compat npx canton-dev-tools sync-splice-dars npx canton-dev-tools codegen-js +npx canton-dev-tools bundle-dependencies +npx canton-dev-tools create-root-index +npx canton-dev-tools fix-splice-refs --target lib npx canton-dev-tools prepare-release --changelog-repo Fairmint/canton-assets ``` @@ -56,34 +59,111 @@ Generic DAML → JS bindings steps for packages that declare `codegen.js` in `da 3. Write per-package `index.js` / `index.d.ts` 4. Fix Splice namespace refs on generated `lib/` trees (optional `@fairmint/*` → `__bundled__` rewrite when present) -**Still consumer-local (Phase 2):** `bundle-dependencies`, `create-root-index`, merged-lib verify lists. +### Phase 2: `bundle-dependencies` + `create-root-index` -Optional publish suffixes in root `package.json` (multi-package repos): +Config-driven stdlib/Splice bundling and merged published `lib/` creation. Driven by +`daml-js-bundle.json` (or `--config` / `package.json` → `cantonDevTools.damlJsBundle`). +**No product package names are hardcoded** in canton-dev-tools — consumers select presets and +describe their root index in JSON. + +```bash +npx canton-dev-tools bundle-dependencies [--root ] [--config ] +npx canton-dev-tools create-root-index [--root ] [--config ] +npx canton-dev-tools fix-splice-refs --target lib +``` + +Built-in presets (stdlib / Splice only): + +| Preset id | Bundles | +|---|---| +| `da-internal-template` | `ghc-stdlib-DA-Internal-Template` (always applied) | +| `featured-app-v1` | `splice-api-featured-app-v1` | +| `featured-app-v2` | `splice-api-featured-app-v2` (only when amulet needs it) | +| `amulet` | `splice-amulet-` | +| `da-time-types` | `daml-stdlib-DA-Time-Types` | +| `da-types` | `daml-prim-DA-Types` | +| `da-set-types` | `daml-stdlib-DA-Set-Types` | +| `splice-token-v1` | token burn/mint, metadata, holding, allocation*, transfer-instruction | +| `splice-token-standard-utils` | `splice-token-standard-utils-` | + +Pins (optional): `pins.amulet` (default `0.1.19`), `pins.tokenStandardUtils` (default `2.0.0`). + +Example `daml-js-bundle.json` (assets-like shape; product names belong in the **consumer** config): ```json { - "cantonDevTools": { - "codegenPublishSuffixes": { - "OpenCapTableReports-v01": "reports", - "WrappedAssets-v01": null - } + "generatedJsDir": "generated/js", + "presets": [ + "da-internal-template", + "featured-app-v1", + "featured-app-v2", + "amulet", + "da-time-types", + "da-types", + "da-set-types", + "splice-token-v1", + "splice-token-standard-utils" + ], + "pins": { + "amulet": "0.1.19", + "tokenStandardUtils": "2.0.0" + }, + "rootIndex": { + "outputDir": "lib", + "sourcePackage": { "namePrefix": "WrappedAssets" }, + "copy": ["DA", "Splice", "__bundled__", "WrappedAssets"], + "namespaces": ["WrappedAssets", "DA", "Splice"], + "templateConstants": { + "WRAPPED_ASSETS_TEMPLATES": { + "burnMintFactory": { + "from": "./WrappedAssets/BurnMint/module", + "binding": "WrappedAssetsBurnMintFactory" + }, + "burnOffer": { + "from": "./WrappedAssets/BurnOffer/module", + "binding": "BurnOffer" + }, + "wrappedAsset": { + "from": "./WrappedAssets/Holding/module", + "binding": "WrappedAsset" + }, + "frozenWrappedAsset": { + "from": "./WrappedAssets/Holding/module", + "binding": "FrozenWrappedAsset" + } + } + }, + "postBundlePresets": [ + "da-time-types", + "da-types", + "splice-token-v1", + "splice-token-standard-utils", + "da-set-types" + ] } } ``` -`null` publishes as the root package name. A single codegen package defaults to the root name. +Or point at the file from `package.json`: + +```json +{ + "cantonDevTools": { + "damlJsBundle": "./daml-js-bundle.json" + } +} +``` Library imports: ```ts import { runCodegenJs, - createPackageIndexes, - updateGeneratedPackagesFromRoot, + bundleDependencies, + createRootIndex, fixSpliceRefs, - collapseManifestLines, - verifyPackageImports, - applyGeneratedImportRewrites, + resolveDamlJsBundleConfig, + BUNDLE_PRESET_IDS, } from '@fairmint/canton-dev-tools/daml'; ``` @@ -93,15 +173,42 @@ Example consumer scripts: { "scripts": { "prepare-build": "canton-dev-tools prepare-build", - "codegen": "npm run build && canton-dev-tools codegen-js", - "prepare-release": "canton-dev-tools prepare-release", - "package:manifest": "… | canton-dev-tools collapse-manifest > generated/npm-manifest.txt", - "package:prep": "npm run codegen && npm run update-version && tsx scripts/bundle-dependencies.ts && tsx scripts/create-root-index.ts && tsx scripts/fix-splice-refs.ts" + "codegen": "npm run build && canton-dev-tools codegen-js && canton-dev-tools bundle-dependencies && canton-dev-tools create-root-index && canton-dev-tools fix-splice-refs --target lib", + "prepare-release": "canton-dev-tools prepare-release" + } +} +``` + +NFT / CapTable merge hooks stay consumer-local (out of scope for Phase 2). + +Optional publish suffixes in root `package.json` (multi-package repos): + +```json +{ + "cantonDevTools": { + "codegenPublishSuffixes": { + "OpenCapTableReports-v01": "reports", + "WrappedAssets-v01": null + } } } ``` -Prefer calling library helpers from thin consumer scripts when you need a custom step order (assets: bundle → create-root-index → fix-splice-refs on merged `lib/`). +`null` publishes as the root package name. A single codegen package defaults to the root name. + +Library imports (Phase 1 helpers): + +```ts +import { + runCodegenJs, + createPackageIndexes, + updateGeneratedPackagesFromRoot, + fixSpliceRefs, + collapseManifestLines, + verifyPackageImports, + applyGeneratedImportRewrites, +} from '@fairmint/canton-dev-tools/daml'; +``` ### `check-dar-version-policy` extra watch paths diff --git a/bin/canton-dev-tools b/bin/canton-dev-tools index 3d46844..616ab18 100755 --- a/bin/canton-dev-tools +++ b/bin/canton-dev-tools @@ -50,6 +50,9 @@ DAML package commands (from a multi-package repo root): sync-splice-dars [--config ] [--force] install-dpm-sdks codegen-js [--root ] [--skip-dpm] + bundle-dependencies [--root ] [--config ] + create-root-index [--root ] [--config ] + fix-splice-refs [--root ] [--target ] prepare-release [--root ] [--changelog-repo owner/repo] collapse-manifest @@ -57,6 +60,8 @@ One-liners: npx @fairmint/canton-dev-tools start npx @fairmint/canton-dev-tools prepare-build npx @fairmint/canton-dev-tools codegen-js + npx @fairmint/canton-dev-tools bundle-dependencies + npx @fairmint/canton-dev-tools create-root-index Environment: CANTON_LOCALNET_QUICKSTART_DIR Use an existing cn-quickstart/quickstart directory @@ -216,7 +221,7 @@ main() { fi exit 0 ;; - prepare-build | verify-dars | backup-dar | check-dar-version-policy | check-upgrade-compat | check-upgrade-compatibility | sync-splice-dars | codegen-js | prepare-release | collapse-manifest) + prepare-build | verify-dars | backup-dar | check-dar-version-policy | check-upgrade-compat | check-upgrade-compatibility | sync-splice-dars | codegen-js | bundle-dependencies | create-root-index | fix-splice-refs | prepare-release | collapse-manifest) shift || true run_daml_cli "${command}" "$@" ;; diff --git a/src/cli.ts b/src/cli.ts index e8fa1f2..a257d91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** * CLI entry for DAML package tooling subcommands. * @@ -10,8 +9,11 @@ import * as path from 'node:path'; import { runBackupDarCli } from './daml/backup-dar'; import { runCheckDarVersionPolicyCli } from './daml/check-dar-version-policy'; import { runCheckUpgradeCompatibilityCli } from './daml/check-upgrade-compatibility'; +import { runBundleDependenciesCli } from './daml/codegen/bundle-dependencies'; import { runCodegenJsCli } from './daml/codegen/codegen-js'; import { collapseManifestFromStdin } from './daml/codegen/collapse-manifest'; +import { runCreateRootIndexCli } from './daml/codegen/create-root-index'; +import { fixSpliceRefs } from './daml/codegen/fix-splice-refs'; import { parseFlagValue } from './daml/packages'; import { prepareBuild } from './daml/prepare-build'; import { runSyncSpliceDarsCli } from './daml/sync-splice-dars'; @@ -30,15 +32,23 @@ DAML package commands (run from a multi-package repo root): sync-splice-dars Fetch pinned Splice DARs (packaged default or splice-dars.json) install-dpm-sdks Install Daml SDKs from daml.yaml (shell helper) codegen-js Run dpm codegen-js + generic post-steps + bundle-dependencies Bundle stdlib/Splice deps into generated packages + create-root-index Build merged published lib/ from daml-js-bundle.json + fix-splice-refs Fix Splice namespace refs (optional --target) prepare-release Floor-style version bump + CHANGELOG.md collapse-manifest Collapse npm pack paths from stdin Common options: --root Repo root (default: cwd) + --config daml-js-bundle.json (bundle-dependencies / create-root-index) codegen-js options: --skip-dpm Only run post-processing (update/index/fix-splice-refs) +fix-splice-refs options: + --target Directory to walk (default: /lib) + --no-rewrite-fairmint Skip @fairmint/daml.js → __bundled__ rewrites + prepare-release options: --changelog-repo GitHub repo for previous-version changelog links (default: package.json repository field) @@ -58,6 +68,16 @@ function resolveRoot(args: string[]): string { return path.resolve(parseFlagValue(args, '--root') ?? process.cwd()); } +function runFixSpliceRefsCli(args: string[]): void { + const rootDir = resolveRoot(args); + const target = parseFlagValue(args, '--target'); + const targetDir = path.resolve(target ?? path.join(rootDir, 'lib')); + fixSpliceRefs({ + targetDir, + rewriteFairmintScopedImports: !args.includes('--no-rewrite-fairmint'), + }); +} + function main(): void { const [command, ...args] = process.argv.slice(2); if (!command || command === '-h' || command === '--help' || command === 'help') { @@ -95,6 +115,18 @@ function main(): void { runCodegenJsCli(args); break; } + case 'bundle-dependencies': { + runBundleDependenciesCli(args); + break; + } + case 'create-root-index': { + runCreateRootIndexCli(args); + break; + } + case 'fix-splice-refs': { + runFixSpliceRefsCli(args); + break; + } case 'prepare-release': { runPrepareReleaseCli(args); break; diff --git a/src/daml/codegen/bundle-dependencies.ts b/src/daml/codegen/bundle-dependencies.ts new file mode 100644 index 0000000..15f1c65 --- /dev/null +++ b/src/daml/codegen/bundle-dependencies.ts @@ -0,0 +1,374 @@ +/** + * Config-driven dependency bundling for generated DAML→JS packages. + * + * Ports the canton-assets bundling engine without hardcoding product package names. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseFlagValue } from '../packages'; +import { getErrorMessage, type PackageJson } from '../types'; +import { + ensureBundledDANamespaceIndexes, + ensureBundledSpliceNamespaceIndexes, + normalizeImportTarget, + removeDirectoryIfExists, +} from './bundle-fs'; +import { + getBundledArtifactDirs, + resolvePresetIds, + type BundlePresetId, + type BundlePins, +} from './bundle-presets'; +import { + resolveDamlJsBundleConfig, + type ResolvedDamlJsBundleConfig, +} from './daml-js-bundle-config'; +import { discoverCodegenPackages } from './discover-codegen-packages'; +import { + applyGeneratedImportRewrites, + collectGeneratedOutputFiles, + type GeneratedImportRewriteRule, +} from './generated-output-helpers'; + +export interface BundleDependenciesOptions { + rootDir: string; + configPath?: string; + /** Override packages to process (absolute generated package dirs). */ + packageDirs?: string[]; + /** Force-apply these presets (skip detection). Used by create-root-index post-steps. */ + forcePresets?: BundlePresetId[]; + /** Target package roots (default: discovered codegen packages). */ + targetDirs?: string[]; +} + +function packageHasDependencyReference( + targetDir: string, + rawImports: string[], + bundledTargets: string[] +): boolean { + const normalizedTargets = bundledTargets.map((bundledTarget) => + normalizeImportTarget(bundledTarget) + ); + const moduleSpecifierPatterns = [/require\(['"]([^'"]+)['"]\)/g, /from ['"]([^'"]+)['"]/g]; + + for (const filePath of collectGeneratedOutputFiles(path.join(targetDir, 'lib'), { + ignoredDirs: getBundledArtifactDirs(targetDir), + })) { + const fileContents = fs.readFileSync(filePath, 'utf8'); + + if (rawImports.some((rawImport) => fileContents.includes(rawImport))) { + return true; + } + + for (const pattern of moduleSpecifierPatterns) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null = pattern.exec(fileContents); + + while (match) { + const specifier = match[1]; + if (specifier?.startsWith('.')) { + const resolvedImport = normalizeImportTarget( + path.resolve(path.dirname(filePath), specifier) + ); + if (normalizedTargets.some((bundledTarget) => bundledTarget === resolvedImport)) { + return true; + } + } + match = pattern.exec(fileContents); + } + } + } + + return false; +} + +function clearBundledArtifacts(targetDir: string): void { + for (const bundledDir of getBundledArtifactDirs(targetDir)) { + removeDirectoryIfExists(bundledDir); + } +} + +function normalizeMainIndexJs(content: string, hasSpliceDir: boolean): string { + let normalizedContent = content + .replace(/var DA = require\('\.\/DA'\);\nexports\.DA = DA;\n?/g, '') + .replace(/var Splice = require\('\.\/Splice'\);\nexports\.Splice = Splice;\n?/g, '') + .trimEnd(); + + normalizedContent = `${normalizedContent}\nvar DA = require('./DA');\nexports.DA = DA;\n`; + + if (hasSpliceDir) { + normalizedContent = `${normalizedContent}var Splice = require('./Splice');\nexports.Splice = Splice;\n`; + } + + return normalizedContent; +} + +function normalizeMainIndexDts(content: string, hasSpliceDir: boolean): string { + const importsToAdd = ["import * as DA from './DA';"]; + if (hasSpliceDir) { + importsToAdd.push("import * as Splice from './Splice';"); + } + + let normalizedContent = content + .replace(/^import \* as DA from '\.\/DA';\n?/gm, '') + .replace(/^import \* as Splice from '\.\/Splice';\n?/gm, ''); + + const exportMatch = normalizedContent.match(/export \{([^}]*)\} ;/); + const exportNames = exportMatch + ? exportMatch[1]! + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .filter((name) => name !== 'DA' && name !== 'Splice') + : []; + + exportNames.push('DA'); + if (hasSpliceDir) { + exportNames.push('Splice'); + } + + const exportLine = `export { ${[...new Set(exportNames)].join(', ')} } ;`; + normalizedContent = exportMatch + ? normalizedContent.replace(/export \{[^}]*\} ;/, exportLine) + : `${normalizedContent.trimEnd()}\n${exportLine}\n`; + + const lines = normalizedContent.split('\n'); + const firstNonImportIndex = lines.findIndex( + (line) => line.trim() !== '' && !line.startsWith('import ') + ); + const insertIndex = firstNonImportIndex === -1 ? lines.length : firstNonImportIndex; + lines.splice(insertIndex, 0, ...importsToAdd); + + return `${lines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; +} + +function updateMainIndex(targetDir: string): void { + console.log('📝 Updating main index files...'); + const hasSpliceDir = + fs.existsSync(path.join(targetDir, 'lib/Splice/index.js')) && + fs.existsSync(path.join(targetDir, 'lib/Splice/index.d.ts')); + + const mainIndexPath = path.join(targetDir, 'lib/index.js'); + if (fs.existsSync(mainIndexPath)) { + const mainIndex = fs.readFileSync(mainIndexPath, 'utf8'); + const normalizedMainIndex = normalizeMainIndexJs(mainIndex, hasSpliceDir); + if (normalizedMainIndex !== mainIndex) { + fs.writeFileSync(mainIndexPath, normalizedMainIndex); + console.log('✅ Updated main index.js'); + } + } + + const mainIndexDtsPath = path.join(targetDir, 'lib/index.d.ts'); + if (fs.existsSync(mainIndexDtsPath)) { + const mainIndexDts = fs.readFileSync(mainIndexDtsPath, 'utf8'); + const normalizedMainIndexDts = normalizeMainIndexDts(mainIndexDts, hasSpliceDir); + if (normalizedMainIndexDts !== mainIndexDts) { + fs.writeFileSync(mainIndexDtsPath, normalizedMainIndexDts); + console.log('✅ Updated main index.d.ts'); + } + } +} + +function removeLocalDependencies( + targetDir: string, + deps: string[] +): void { + console.log('🗑️ Removing local dependencies from package.json...'); + const packageJsonPath = path.join(targetDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + console.log('ℹ️ No package.json found'); + return; + } + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson; + let removedCount = 0; + for (const dep of deps) { + if (packageJson.dependencies?.[dep]) { + delete packageJson.dependencies[dep]; + removedCount++; + console.log(`✅ Removed local dependency: ${dep}`); + } + } + if (removedCount > 0) { + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4)); + console.log(`✅ Removed ${removedCount} local dependencies from package.json`); + } else { + console.log('ℹ️ No local dependencies found in package.json'); + } +} + +/** + * Bundle selected presets into a single generated package directory + * (`…/generated/js/-/` with a `lib/` child). + */ +export function bundleDependenciesForTarget(options: { + targetDir: string; + generatedJsDir: string; + pins: BundlePins; + presets: BundlePresetId[]; + /** When set, skip detection and always apply these presets. */ + forcePresets?: BundlePresetId[]; + /** Clear stale bundled artifacts before detecting (default true). */ + clearFirst?: boolean; + /** Update lib/index to export DA/Splice (default true). */ + updateIndex?: boolean; + /** Strip bundled deps from package.json (default true). */ + cleanPackageJson?: boolean; +}): BundlePresetId[] { + const targetDir = path.resolve(options.targetDir); + const clearFirst = options.clearFirst ?? true; + const updateIndex = options.updateIndex ?? true; + const cleanPackageJson = options.cleanPackageJson ?? true; + + if (clearFirst) { + clearBundledArtifacts(targetDir); + } + + const presetDefs = resolvePresetIds(options.presets); + const forceSet = new Set(options.forcePresets ?? []); + + // Detect amulet first so featured-app-v2 can use willBundleAmulet. + const amuletPreset = presetDefs.find((preset) => preset.id === 'amulet'); + const amuletDetected = amuletPreset + ? packageHasDependencyReference( + targetDir, + amuletPreset.importSpecs(options.pins), + // Amulet detection historically used lib/ as the relative target marker. + amuletPreset.detectionTargets(targetDir) + ) + : false; + const willBundleAmulet = forceSet.has('amulet') || amuletDetected; + + const applied: BundlePresetId[] = []; + const rewriteRules: GeneratedImportRewriteRule[] = []; + const packageJsonDeps: string[] = []; + + for (const preset of presetDefs) { + const detected = packageHasDependencyReference( + targetDir, + preset.importSpecs(options.pins), + preset.detectionTargets(targetDir) + ); + const shouldApply = forceSet.has(preset.id) + ? true + : preset.shouldApply + ? preset.shouldApply( + { + targetDir, + generatedJsDir: options.generatedJsDir, + pins: options.pins, + willBundleAmulet, + }, + detected + ) + : detected; + + if (!shouldApply) { + continue; + } + + preset.apply({ + targetDir, + generatedJsDir: options.generatedJsDir, + pins: options.pins, + willBundleAmulet, + }); + applied.push(preset.id); + rewriteRules.push(...preset.rewriteRules(targetDir, options.pins)); + packageJsonDeps.push(...preset.importSpecs(options.pins)); + } + + ensureBundledDANamespaceIndexes(targetDir); + ensureBundledSpliceNamespaceIndexes(targetDir); + + if (updateIndex) { + updateMainIndex(targetDir); + } + + console.log('🔄 Replacing dependency references in generated files...'); + const replacedCount = applyGeneratedImportRewrites(path.join(targetDir, 'lib'), rewriteRules); + console.log(`✅ Replaced dependency references in ${replacedCount} files`); + + if (cleanPackageJson) { + removeLocalDependencies(targetDir, [...new Set(packageJsonDeps)]); + } + + return applied; +} + +export function bundleDependencies( + options: BundleDependenciesOptions +): { config: ResolvedDamlJsBundleConfig; processed: string[]; applied: Record } { + const config = resolveDamlJsBundleConfig({ + rootDir: options.rootDir, + configPath: options.configPath, + allowMissing: true, + }); + + const targetDirs = + options.targetDirs ?? + options.packageDirs ?? + discoverCodegenPackages({ + rootDir: config.rootDir, + generatedJsRoot: config.generatedJsDir, + }).map((pkg) => pkg.absoluteGeneratedJsDir); + + console.log('🚀 Starting dependency bundling...'); + const applied: Record = {}; + const processed: string[] = []; + + for (const targetDir of targetDirs) { + if (!fs.existsSync(targetDir)) { + console.log(`ℹ️ Skipping missing package dir: ${targetDir}`); + continue; + } + console.log(`📦 Processing package: ${targetDir}`); + processed.push(targetDir); + applied[targetDir] = bundleDependenciesForTarget({ + targetDir, + generatedJsDir: config.absoluteGeneratedJsDir, + pins: config.pins, + presets: config.presets, + forcePresets: options.forcePresets, + }); + } + + console.log('✅ Dependency bundling completed successfully!'); + return { config, processed, applied }; +} + +/** Force-apply presets into an existing package/repo root (used by create-root-index). */ +export function applyBundlePresets(options: { + targetDir: string; + generatedJsDir: string; + pins: BundlePins; + presets: BundlePresetId[]; +}): BundlePresetId[] { + return bundleDependenciesForTarget({ + targetDir: options.targetDir, + generatedJsDir: options.generatedJsDir, + pins: options.pins, + presets: options.presets, + forcePresets: options.presets, + clearFirst: false, + updateIndex: false, + cleanPackageJson: false, + }); +} + +export function runBundleDependenciesCli(args: string[]): void { + try { + const rootDir = path.resolve(parseFlagValue(args, '--root') ?? process.cwd()); + const configPath = parseFlagValue(args, '--config'); + bundleDependencies({ + rootDir, + ...(configPath ? { configPath } : {}), + }); + } catch (error) { + console.error(`❌ Error during dependency bundling: ${getErrorMessage(error)}`); + process.exit(1); + } +} diff --git a/src/daml/codegen/bundle-fs.ts b/src/daml/codegen/bundle-fs.ts new file mode 100644 index 0000000..0a244d3 --- /dev/null +++ b/src/daml/codegen/bundle-fs.ts @@ -0,0 +1,115 @@ +/** Shared filesystem helpers for DAML→JS bundling / root-index merge. */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function createDirectoryIfNotExists(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +export function copyFile(src: string, dest: string): void { + createDirectoryIfNotExists(path.dirname(dest)); + fs.copyFileSync(src, dest); +} + +export function copyDirectory(src: string, dest: string): void { + if (!fs.existsSync(src)) { + return; + } + createDirectoryIfNotExists(dest); + for (const item of fs.readdirSync(src)) { + const srcPath = path.join(src, item); + const destPath = path.join(dest, item); + const stat = fs.statSync(srcPath); + if (stat.isDirectory()) { + copyDirectory(srcPath, destPath); + } else { + copyFile(srcPath, destPath); + } + } +} + +export function removeDirectoryIfExists(dirPath: string): void { + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }); + } +} + +export function getImmediateChildDirs(dirPath: string): string[] { + if (!fs.existsSync(dirPath)) { + return []; + } + return fs + .readdirSync(dirPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +export function createNamespaceIndexDts(childNamespaces: string[]): string { + return `${childNamespaces + .map((childNamespace) => `export * as ${childNamespace} from './${childNamespace}';`) + .join('\n')} +`; +} + +export function writeNamespaceIndexFiles(dirPath: string, childNamespaces: string[]): void { + if (childNamespaces.length === 0) { + return; + } + createDirectoryIfNotExists(dirPath); + const indexJs = `"use strict"; +/* eslint-disable-next-line no-unused-vars */ +function __export(m) { +/* eslint-disable-next-line no-prototype-builtins */ + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +Object.defineProperty(exports, "__esModule", { value: true }); +${childNamespaces + .map( + (childNamespace) => + `var ${childNamespace} = require('./${childNamespace}');\nexports.${childNamespace} = ${childNamespace};` + ) + .join('\n')} +`; + fs.writeFileSync(path.join(dirPath, 'index.js'), indexJs); + fs.writeFileSync(path.join(dirPath, 'index.d.ts'), createNamespaceIndexDts(childNamespaces)); +} + +export function ensureBundledSpliceNamespaceIndexes(targetDir: string): void { + const spliceDir = path.join(targetDir, 'lib/Splice'); + const apiDir = path.join(spliceDir, 'Api'); + const tokenDir = path.join(apiDir, 'Token'); + + const tokenNamespaces = getImmediateChildDirs(tokenDir); + if (tokenNamespaces.length > 0) { + writeNamespaceIndexFiles(tokenDir, tokenNamespaces); + } + + const apiNamespaces = getImmediateChildDirs(apiDir); + if (apiNamespaces.length > 0) { + writeNamespaceIndexFiles(apiDir, apiNamespaces); + } + + const spliceNamespaces = getImmediateChildDirs(spliceDir); + if (spliceNamespaces.length > 0) { + writeNamespaceIndexFiles(spliceDir, spliceNamespaces); + } +} + +export function ensureBundledDANamespaceIndexes(targetDir: string): void { + const daDir = path.join(targetDir, 'lib/DA'); + const daNamespaces = getImmediateChildDirs(daDir); + if (daNamespaces.length > 0) { + writeNamespaceIndexFiles(daDir, daNamespaces); + } +} + +export function normalizeImportTarget(importPath: string): string { + return path + .normalize(importPath) + .replace(/(\.d\.ts|\.js)$/, '') + .replace(/[/\\]index$/, ''); +} diff --git a/src/daml/codegen/bundle-presets.ts b/src/daml/codegen/bundle-presets.ts new file mode 100644 index 0000000..4ef7fb5 --- /dev/null +++ b/src/daml/codegen/bundle-presets.ts @@ -0,0 +1,666 @@ +/** + * Built-in stdlib / Splice dependency presets for DAML→JS bundling. + * + * Product packages (WrappedAssets, OCP, NFT, …) are never named here — consumers + * select presets via `daml-js-bundle.json`. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { GeneratedImportRewriteRule } from './generated-output-helpers'; +import { writeGeneratedOutputPair } from './generated-output-helpers'; +import { + copyDirectory, + createDirectoryIfNotExists, + createNamespaceIndexDts, +} from './bundle-fs'; + +export const BUNDLE_PRESET_IDS = [ + 'da-internal-template', + 'featured-app-v1', + 'featured-app-v2', + 'amulet', + 'da-time-types', + 'da-types', + 'da-set-types', + 'splice-token-v1', + 'splice-token-standard-utils', +] as const; + +export type BundlePresetId = (typeof BUNDLE_PRESET_IDS)[number]; + +export interface BundlePins { + amulet: string; + tokenStandardUtils: string; +} + +export interface BundleApplyContext { + targetDir: string; + generatedJsDir: string; + pins: BundlePins; + /** Whether amulet will be / was requested for this package (affects featured-app-v2). */ + willBundleAmulet: boolean; +} + +function importVariants(packageNameWithVersion: string): string[] { + return [ + `daml.js/${packageNameWithVersion}`, + `@daml.js/${packageNameWithVersion}`, + `@fairmint/${packageNameWithVersion}`, + ]; +} + +function packageDir(generatedJsDir: string, nameWithVersion: string): string { + return path.join(generatedJsDir, nameWithVersion); +} + +function ensureWrapper( + targetDir: string, + wrapperName: string, + js: string, + dts: string +): void { + const wrapperDir = path.join(targetDir, 'lib', '__bundled__', wrapperName); + writeGeneratedOutputPair(wrapperDir, 'index', { js, dts }); +} + +function writeModuleIndexPair(dirPath: string): void { + writeGeneratedOutputPair(dirPath, 'index', { + js: `"use strict"; +/* eslint-disable-next-line no-unused-vars */ +function __export(m) { +/* eslint-disable-next-line no-prototype-builtins */ + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +Object.defineProperty(exports, "__esModule", { value: true }); +__export(require('./module')); +`, + dts: `export * from './module'; +`, + }); +} + +function writeNamespaceChildPair(dirPath: string, childName: string): void { + writeGeneratedOutputPair(dirPath, 'index', { + js: `"use strict"; +/* eslint-disable-next-line no-unused-vars */ +function __export(m) { +/* eslint-disable-next-line no-prototype-builtins */ + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +Object.defineProperty(exports, "__esModule", { value: true }); +var ${childName} = require('./${childName}'); +exports.${childName} = ${childName}; +`, + dts: createNamespaceIndexDts([childName]), + }); +} + +function copyModuleTreeOrWarn( + sourceDir: string, + destDir: string, + label: string +): boolean { + if (!fs.existsSync(sourceDir)) { + console.log(`⚠️ ${label} not found at ${sourceDir}`); + return false; + } + copyDirectory(sourceDir, destDir); + return true; +} + +export interface BundlePresetDefinition { + id: BundlePresetId; + /** Import strings used for detection + package.json cleanup. */ + importSpecs: (pins: BundlePins) => string[]; + /** Absolute paths under targetDir that count as "already bundled" for detection. */ + detectionTargets: (targetDir: string) => string[]; + /** Artifact dirs cleared before detection (subset of getBundledArtifactDirs). */ + clearDirs?: (targetDir: string) => string[]; + /** + * Whether this preset should apply. Default: detect via importSpecs/detectionTargets. + * Special cases (featured-app-v2, always-on da-internal-template) override. + */ + shouldApply?: (ctx: BundleApplyContext, detected: boolean) => boolean; + apply: (ctx: BundleApplyContext) => void; + rewriteRules: (targetDir: string, pins: BundlePins) => GeneratedImportRewriteRule[]; +} + +function amuletPackageName(pins: BundlePins): string { + return `splice-amulet-${pins.amulet}`; +} + +function tokenStandardUtilsPackageName(pins: BundlePins): string { + return `splice-token-standard-utils-${pins.tokenStandardUtils}`; +} + +function amuletModuleReferencesFeaturedAppV2(content: string): boolean { + return ( + content.includes('daml.js/splice-api-featured-app-v2-1.0.0') || + content.includes('@daml.js/splice-api-featured-app-v2-1.0.0') || + content.includes('@fairmint/splice-api-featured-app-v2-1.0.0') + ); +} + +function packageNeedsFeaturedAppV2(ctx: BundleApplyContext): boolean { + const embeddedAmulet = path.join(ctx.targetDir, 'lib/Splice/Amulet/module.js'); + if (fs.existsSync(embeddedAmulet)) { + return amuletModuleReferencesFeaturedAppV2(fs.readFileSync(embeddedAmulet, 'utf8')); + } + if (ctx.willBundleAmulet) { + const templateAmulet = path.join( + packageDir(ctx.generatedJsDir, amuletPackageName(ctx.pins)), + 'lib/Splice/Amulet/module.js' + ); + return ( + fs.existsSync(templateAmulet) && + amuletModuleReferencesFeaturedAppV2(fs.readFileSync(templateAmulet, 'utf8')) + ); + } + return false; +} + +const TOKEN_V1_PACKAGES: Array<{ + dirName: string; + relModule: string; + wrapperKey: string; + wrapperName: string; +}> = [ + { + dirName: 'splice-api-token-burn-mint-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/BurnMintV1', + wrapperKey: 'BurnMintV1', + wrapperName: 'splice-api-token-burn-mint-v1', + }, + { + dirName: 'splice-api-token-metadata-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/MetadataV1', + wrapperKey: 'MetadataV1', + wrapperName: 'splice-api-token-metadata-v1', + }, + { + dirName: 'splice-api-token-holding-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/HoldingV1', + wrapperKey: 'HoldingV1', + wrapperName: 'splice-api-token-holding-v1', + }, + { + dirName: 'splice-api-token-allocation-instruction-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/AllocationInstructionV1', + wrapperKey: 'AllocationInstructionV1', + wrapperName: 'splice-api-token-allocation-instruction-v1', + }, + { + dirName: 'splice-api-token-transfer-instruction-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/TransferInstructionV1', + wrapperKey: 'TransferInstructionV1', + wrapperName: 'splice-api-token-transfer-instruction-v1', + }, + { + dirName: 'splice-api-token-allocation-v1-1.0.0', + relModule: 'lib/Splice/Api/Token/AllocationV1', + wrapperKey: 'AllocationV1', + wrapperName: 'splice-api-token-allocation-v1', + }, +]; + +export const BUNDLE_PRESETS: Record = { + 'da-internal-template': { + id: 'da-internal-template', + importSpecs: () => importVariants('ghc-stdlib-DA-Internal-Template-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'DA', 'Internal', 'Template'), + path.join(targetDir, 'lib', '__bundled__', 'ghc-stdlib-DA-Internal-Template'), + ], + shouldApply: () => true, + apply: (ctx) => { + console.log('📦 Bundling DA.Internal.Template dependency...'); + const templateDir = path.join(ctx.targetDir, 'lib/DA/Internal/Template'); + createDirectoryIfNotExists(templateDir); + const depRoot = packageDir(ctx.generatedJsDir, 'ghc-stdlib-DA-Internal-Template-1.0.0'); + const moduleSrc = path.join(depRoot, 'lib/DA/Internal/Template/module.js'); + const moduleDtsSrc = path.join(depRoot, 'lib/DA/Internal/Template/module.d.ts'); + + if (fs.existsSync(moduleSrc)) { + fs.copyFileSync(moduleSrc, path.join(templateDir, 'module.js')); + } else { + console.log('⚠️ module.js not found in dependency, creating minimal version'); + fs.writeFileSync( + path.join(templateDir, 'module.js'), + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var jtv = require('@mojotech/json-type-validation'); +var damlTypes = require('@daml/types'); +exports.Archive = { + decoder: damlTypes.lazyMemo(function () { return jtv.object({}); }), + encode: function (__typed__) { return {}; }, +}; +` + ); + } + + if (fs.existsSync(moduleDtsSrc)) { + fs.copyFileSync(moduleDtsSrc, path.join(templateDir, 'module.d.ts')); + } else { + console.log('⚠️ module.d.ts not found in dependency, creating minimal version'); + fs.writeFileSync( + path.join(templateDir, 'module.d.ts'), + `import * as damlTypes from '@daml/types'; +export declare type Archive = {}; +export declare const Archive: damlTypes.Serializable; +` + ); + } + + writeModuleIndexPair(templateDir); + writeNamespaceChildPair(path.join(ctx.targetDir, 'lib/DA/Internal'), 'Template'); + writeNamespaceChildPair(path.join(ctx.targetDir, 'lib/DA'), 'Internal'); + + ensureWrapper( + ctx.targetDir, + 'ghc-stdlib-DA-Internal-Template', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var Template = require('../../DA/Internal/Template'); +exports.DA = { Internal: { Template: Template } }; +`, + `import * as Template from '../../DA/Internal/Template'; +export declare const DA: { Internal: { Template: typeof Template } }; +` + ); + console.log('✅ Created bundled DA.Internal.Template structure'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('ghc-stdlib-DA-Internal-Template-1.0.0'), + resolveTarget: () => + path.join(targetDir, 'lib/__bundled__/ghc-stdlib-DA-Internal-Template'), + logLabel: 'DA', + }, + ], + }, + + 'featured-app-v1': { + id: 'featured-app-v1', + importSpecs: () => importVariants('splice-api-featured-app-v1-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'Splice', 'Api', 'FeaturedAppRightV1'), + path.join(targetDir, 'lib', '__bundled__', 'splice-api-featured-app-v1'), + ], + apply: (ctx) => { + console.log('📦 Bundling splice-api-featured-app-v1 dependency...'); + const spliceDir = path.join(ctx.targetDir, 'lib/Splice/Api/FeaturedAppRightV1'); + createDirectoryIfNotExists(spliceDir); + const depRoot = packageDir(ctx.generatedJsDir, 'splice-api-featured-app-v1-1.0.0'); + const moduleSrc = path.join(depRoot, 'lib/Splice/Api/FeaturedAppRightV1/module.js'); + const moduleDtsSrc = path.join(depRoot, 'lib/Splice/Api/FeaturedAppRightV1/module.d.ts'); + + if (fs.existsSync(moduleSrc)) { + fs.copyFileSync(moduleSrc, path.join(spliceDir, 'module.js')); + } else { + console.log('⚠️ Splice module.js not found in dependency, creating minimal version'); + fs.writeFileSync( + path.join(spliceDir, 'module.js'), + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var damlTypes = require('@daml/types'); +var jtv = require('@mojotech/json-type-validation'); +exports.FeaturedAppRight = { + decoder: damlTypes.lazyMemo(function () { return jtv.object({}); }), + encode: function (__typed__) { return {}; }, +}; +` + ); + } + + if (fs.existsSync(moduleDtsSrc)) { + fs.copyFileSync(moduleDtsSrc, path.join(spliceDir, 'module.d.ts')); + } else { + fs.writeFileSync( + path.join(spliceDir, 'module.d.ts'), + `import * as damlTypes from '@daml/types'; +export declare type FeaturedAppRight = {}; +export declare const FeaturedAppRight: damlTypes.Serializable; +` + ); + } + + writeModuleIndexPair(spliceDir); + writeNamespaceChildPair(path.join(ctx.targetDir, 'lib/Splice/Api'), 'FeaturedAppRightV1'); + writeNamespaceChildPair(path.join(ctx.targetDir, 'lib/Splice'), 'Api'); + + ensureWrapper( + ctx.targetDir, + 'splice-api-featured-app-v1', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var FeaturedAppRightV1 = require('../../Splice/Api/FeaturedAppRightV1'); +exports.Splice = { Api: { FeaturedAppRightV1: FeaturedAppRightV1 } }; +`, + `import * as FeaturedAppRightV1 from '../../Splice/Api/FeaturedAppRightV1'; +export declare const Splice: { Api: { FeaturedAppRightV1: typeof FeaturedAppRightV1 } }; +` + ); + console.log('✅ Created bundled splice-api-featured-app-v1 structure'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('splice-api-featured-app-v1-1.0.0'), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/splice-api-featured-app-v1'), + logLabel: 'Splice', + }, + ], + }, + + 'featured-app-v2': { + id: 'featured-app-v2', + importSpecs: () => importVariants('splice-api-featured-app-v2-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'Splice', 'Api', 'FeaturedAppRightV2'), + path.join(targetDir, 'lib', '__bundled__', 'splice-api-featured-app-v2'), + ], + shouldApply: (ctx) => packageNeedsFeaturedAppV2(ctx), + apply: (ctx) => { + console.log('📦 Bundling splice-api-featured-app-v2 dependency...'); + const sourceDir = path.join( + packageDir(ctx.generatedJsDir, 'splice-api-featured-app-v2-1.0.0'), + 'lib/Splice/Api/FeaturedAppRightV2' + ); + if (!fs.existsSync(sourceDir)) { + console.log('⚠️ splice-api-featured-app-v2 FeaturedAppRightV2 directory not found'); + return; + } + createDirectoryIfNotExists(path.join(ctx.targetDir, 'lib/Splice/Api')); + copyDirectory(sourceDir, path.join(ctx.targetDir, 'lib/Splice/Api/FeaturedAppRightV2')); + ensureWrapper( + ctx.targetDir, + 'splice-api-featured-app-v2', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var FeaturedAppRightV2 = require('../../Splice/Api/FeaturedAppRightV2'); +exports.Splice = { Api: { FeaturedAppRightV2: FeaturedAppRightV2 } }; +`, + `import * as FeaturedAppRightV2 from '../../Splice/Api/FeaturedAppRightV2'; +export declare const Splice: { Api: { FeaturedAppRightV2: typeof FeaturedAppRightV2 } }; +` + ); + console.log('✅ Copied splice-api-featured-app-v2 FeaturedAppRightV2 modules'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('splice-api-featured-app-v2-1.0.0'), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/splice-api-featured-app-v2'), + logLabel: 'Splice v2', + }, + ], + }, + + amulet: { + id: 'amulet', + importSpecs: (pins) => importVariants(amuletPackageName(pins)), + detectionTargets: (targetDir) => [path.join(targetDir, 'lib')], + apply: (ctx) => { + console.log('📦 Bundling splice-amulet dependency...'); + const spliceSourceDir = path.join( + packageDir(ctx.generatedJsDir, amuletPackageName(ctx.pins)), + 'lib/Splice' + ); + if ( + !copyModuleTreeOrWarn( + spliceSourceDir, + path.join(ctx.targetDir, 'lib/Splice'), + 'splice-amulet Splice directory' + ) + ) { + return; + } + console.log('✅ Copied splice-amulet Splice modules'); + }, + rewriteRules: (targetDir, pins) => [ + { + importPaths: importVariants(amuletPackageName(pins)), + resolveTarget: () => path.join(targetDir, 'lib'), + logLabel: 'splice-amulet', + }, + ], + }, + + 'da-time-types': { + id: 'da-time-types', + importSpecs: () => importVariants('daml-stdlib-DA-Time-Types-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'DA', 'Time', 'Types'), + path.join(targetDir, 'lib', '__bundled__', 'daml-stdlib-DA-Time-Types'), + ], + apply: (ctx) => { + console.log('📦 Bundling DA Time Types dependency...'); + const sourceDir = path.join( + packageDir(ctx.generatedJsDir, 'daml-stdlib-DA-Time-Types-1.0.0'), + 'lib/DA/Time' + ); + if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Time'), 'DA Time Types')) { + return; + } + ensureWrapper( + ctx.targetDir, + 'daml-stdlib-DA-Time-Types', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var Types = require('../../DA/Time/Types'); +exports.DA = { Time: { Types: Types } }; +`, + `import * as Types from '../../DA/Time/Types'; +export declare const DA: { Time: { Types: typeof Types } }; +` + ); + console.log('✅ Copied DA Time Types modules'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('daml-stdlib-DA-Time-Types-1.0.0'), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/daml-stdlib-DA-Time-Types'), + logLabel: 'DA Time Types', + }, + ], + }, + + 'da-types': { + id: 'da-types', + importSpecs: () => importVariants('daml-prim-DA-Types-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'DA', 'Types'), + path.join(targetDir, 'lib', '__bundled__', 'daml-prim-DA-Types'), + ], + apply: (ctx) => { + console.log('📦 Bundling DA Types dependency...'); + const sourceDir = path.join( + packageDir(ctx.generatedJsDir, 'daml-prim-DA-Types-1.0.0'), + 'lib/DA/Types' + ); + if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Types'), 'DA Types')) { + return; + } + ensureWrapper( + ctx.targetDir, + 'daml-prim-DA-Types', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var Types = require('../../DA/Types'); +exports.DA = { Types: Types }; +`, + `import * as Types from '../../DA/Types'; +export declare const DA: { Types: typeof Types }; +` + ); + console.log('✅ Copied DA Types modules'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('daml-prim-DA-Types-1.0.0'), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/daml-prim-DA-Types'), + logLabel: 'DA Types', + }, + ], + }, + + 'da-set-types': { + id: 'da-set-types', + importSpecs: () => importVariants('daml-stdlib-DA-Set-Types-1.0.0'), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'DA', 'Set', 'Types'), + path.join(targetDir, 'lib', '__bundled__', 'daml-stdlib-DA-Set-Types'), + ], + apply: (ctx) => { + console.log('📦 Bundling DA Set Types dependency...'); + const sourceDir = path.join( + packageDir(ctx.generatedJsDir, 'daml-stdlib-DA-Set-Types-1.0.0'), + 'lib/DA/Set' + ); + if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Set'), 'DA Set Types')) { + return; + } + ensureWrapper( + ctx.targetDir, + 'daml-stdlib-DA-Set-Types', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var Types = require('../../DA/Set/Types'); +exports.DA = { Set: { Types: Types } }; +`, + `import * as Types from '../../DA/Set/Types'; +export declare const DA: { Set: { Types: typeof Types } }; +` + ); + console.log('✅ Copied DA Set Types modules'); + }, + rewriteRules: (targetDir) => [ + { + importPaths: importVariants('daml-stdlib-DA-Set-Types-1.0.0'), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/daml-stdlib-DA-Set-Types'), + logLabel: 'DA Set Types', + }, + ], + }, + + 'splice-token-v1': { + id: 'splice-token-v1', + importSpecs: () => + TOKEN_V1_PACKAGES.flatMap((pkg) => importVariants(pkg.dirName)), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'BurnMintV1'), + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'MetadataV1'), + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'HoldingV1'), + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'AllocationInstructionV1'), + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'TransferInstructionV1'), + path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'AllocationV1'), + ...TOKEN_V1_PACKAGES.map((pkg) => + path.join(targetDir, 'lib', '__bundled__', pkg.wrapperName) + ), + ], + apply: (ctx) => { + console.log('📦 Bundling Splice API Token dependencies...'); + for (const pkg of TOKEN_V1_PACKAGES) { + const sourceDir = path.join(packageDir(ctx.generatedJsDir, pkg.dirName), pkg.relModule); + const destDir = path.join(ctx.targetDir, pkg.relModule); + if (fs.existsSync(sourceDir)) { + copyDirectory(sourceDir, destDir); + console.log(`✅ Copied ${pkg.wrapperName}`); + } + } + + for (const pkg of TOKEN_V1_PACKAGES) { + const relPath = `../../Splice/Api/Token/${pkg.wrapperKey}`; + ensureWrapper( + ctx.targetDir, + pkg.wrapperName, + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var mod = require('${relPath}'); +Object.assign(exports, mod); +exports.Splice = { Api: { Token: { ${pkg.wrapperKey}: mod } } }; +`, + `export * from '${relPath}'; +import * as mod from '${relPath}'; +export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } }; +` + ); + } + }, + rewriteRules: (targetDir) => + TOKEN_V1_PACKAGES.map((pkg) => ({ + importPaths: importVariants(pkg.dirName), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__', pkg.wrapperName), + })), + }, + + 'splice-token-standard-utils': { + id: 'splice-token-standard-utils', + importSpecs: (pins) => importVariants(tokenStandardUtilsPackageName(pins)), + detectionTargets: (targetDir) => [ + path.join(targetDir, 'lib', 'Splice', 'TokenStandard'), + path.join(targetDir, 'lib', '__bundled__', 'splice-token-standard-utils'), + ], + apply: (ctx) => { + console.log('📦 Bundling splice-token-standard-utils dependency...'); + const depRoot = packageDir(ctx.generatedJsDir, tokenStandardUtilsPackageName(ctx.pins)); + const sourceDir = path.join(depRoot, 'lib/Splice/TokenStandard'); + if (fs.existsSync(sourceDir)) { + copyDirectory(sourceDir, path.join(ctx.targetDir, 'lib/Splice/TokenStandard')); + } else { + const alt = path.join(depRoot, 'lib/Splice'); + if (!copyModuleTreeOrWarn(alt, path.join(ctx.targetDir, 'lib/Splice'), 'splice-token-standard-utils')) { + return; + } + } + ensureWrapper( + ctx.targetDir, + 'splice-token-standard-utils', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var TokenStandard = require('../../Splice/TokenStandard'); +exports.Splice = { TokenStandard: TokenStandard }; +`, + `import * as TokenStandard from '../../Splice/TokenStandard'; +export declare const Splice: { TokenStandard: typeof TokenStandard }; +` + ); + console.log('✅ Copied splice-token-standard-utils modules'); + }, + rewriteRules: (targetDir, pins) => [ + { + importPaths: importVariants(tokenStandardUtilsPackageName(pins)), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__/splice-token-standard-utils'), + }, + ], + }, +}; + +/** Stable apply order matching the historical assets pipeline. */ +export const BUNDLE_PRESET_APPLY_ORDER: BundlePresetId[] = [ + 'da-internal-template', + 'featured-app-v1', + 'amulet', + 'featured-app-v2', + 'da-time-types', + 'da-types', + 'splice-token-v1', + 'splice-token-standard-utils', + 'da-set-types', +]; + +export function getBundledArtifactDirs(targetDir: string): string[] { + return [ + path.join(targetDir, 'lib', 'Splice'), + path.join(targetDir, 'lib', '__bundled__'), + path.join(targetDir, 'lib', 'DA', 'Time'), + path.join(targetDir, 'lib', 'DA', 'Types'), + path.join(targetDir, 'lib', 'DA', 'Set'), + ]; +} + +export function resolvePresetIds(selected: BundlePresetId[]): BundlePresetDefinition[] { + const selectedSet = new Set(selected); + return BUNDLE_PRESET_APPLY_ORDER.filter((id) => selectedSet.has(id)).map( + (id) => BUNDLE_PRESETS[id] + ); +} diff --git a/src/daml/codegen/codegen-js.ts b/src/daml/codegen/codegen-js.ts index 4d72011..5d76d8d 100644 --- a/src/daml/codegen/codegen-js.ts +++ b/src/daml/codegen/codegen-js.ts @@ -5,9 +5,10 @@ * update-generated-package → create-package-index → fix-splice-refs * on generated JS trees. * - * Consumer-specific steps stay local for Phase 2: + * Phase 2 (config-driven, separate CLI): * - bundle-dependencies * - create-root-index + * - fix-splice-refs --target */ import { spawnSync } from 'node:child_process'; @@ -125,8 +126,8 @@ export function runCodegenJs(options: CodegenJsOptions): CodegenJsResult { console.log( `codegen-js complete for ${packages.map((pkg) => pkg.name).join(', ')}. ` + - 'Consumer steps still required: bundle-dependencies → create-root-index → ' + - 'fix-splice-refs (on merged lib/) → build:ts.' + 'Next: canton-dev-tools bundle-dependencies → create-root-index → ' + + 'fix-splice-refs --target → build:ts.' ); return { packages, updatedDirs }; diff --git a/src/daml/codegen/create-root-index.ts b/src/daml/codegen/create-root-index.ts new file mode 100644 index 0000000..e8f046c --- /dev/null +++ b/src/daml/codegen/create-root-index.ts @@ -0,0 +1,274 @@ +/** + * Config-driven merged root `lib/` builder for published DAML→JS packages. + * + * Product-specific exports / template constants come from `daml-js-bundle.json` + * (`rootIndex`); this engine never hardcodes package product names. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseFlagValue } from '../packages'; +import { getErrorMessage } from '../types'; +import { applyBundlePresets } from './bundle-dependencies'; +import { + copyDirectory, + createDirectoryIfNotExists, + ensureBundledDANamespaceIndexes, + ensureBundledSpliceNamespaceIndexes, + removeDirectoryIfExists, +} from './bundle-fs'; +import { BUNDLE_PRESETS, type BundlePresetId } from './bundle-presets'; +import { + resolveDamlJsBundleConfig, + type ResolvedDamlJsBundleConfig, + type RootIndexConfig, + type RootIndexTemplateEntry, +} from './daml-js-bundle-config'; +import { discoverCodegenPackages, type CodegenPackageConfig } from './discover-codegen-packages'; +import { applyGeneratedImportRewrites } from './generated-output-helpers'; + +export interface CreateRootIndexOptions { + rootDir: string; + configPath?: string; +} + +function resolveSourcePackage( + packages: CodegenPackageConfig[], + selector: RootIndexConfig['sourcePackage'] +): CodegenPackageConfig { + const matches = packages.filter((pkg) => { + if (selector.name && pkg.name === selector.name) return true; + if (selector.key && pkg.key === selector.key) return true; + if (selector.namePrefix && pkg.name.startsWith(selector.namePrefix)) return true; + return false; + }); + + if (matches.length === 0) { + throw new Error( + `No codegen package matched rootIndex.sourcePackage ` + + `(${JSON.stringify(selector)}). Run prepare-build / codegen-js first.` + ); + } + if (matches.length > 1) { + throw new Error( + `Multiple codegen packages matched rootIndex.sourcePackage ` + + `(${JSON.stringify(selector)}): ${matches.map((pkg) => pkg.name).join(', ')}` + ); + } + return matches[0]!; +} + +function jsVarName(modulePath: string, binding: string): string { + const cleaned = modulePath + .replace(/^\.\//, '') + .replace(/\/module$/, '') + .replace(/[^A-Za-z0-9]+/g, '_'); + return `${cleaned}_${binding}`.replace(/^_+/, ''); +} + +function renderTemplateConstantsJs( + constants: Record> +): { requires: string[]; exports: string[] } { + const requires: string[] = []; + const exports: string[] = []; + const seenVars = new Set(); + + for (const [constName, entries] of Object.entries(constants)) { + const fields: string[] = []; + for (const [entryName, entry] of Object.entries(entries)) { + const varName = jsVarName(entry.from, entry.binding); + if (!seenVars.has(varName)) { + seenVars.add(varName); + requires.push(`var ${varName} = require('${entry.from}');`); + } + const field = entry.field ?? 'templateId'; + fields.push(` ${entryName}: ${varName}.${entry.binding}.${field},`); + } + exports.push( + `exports.${constName} = Object.freeze({\n${fields.join('\n')}\n});` + ); + } + + return { requires, exports }; +} + +function renderTemplateConstantsDts( + constants: Record> +): { imports: string[]; declarations: string[] } { + const imports: string[] = []; + const declarations: string[] = []; + const seenVars = new Set(); + + for (const [constName, entries] of Object.entries(constants)) { + const fields: string[] = []; + for (const [entryName, entry] of Object.entries(entries)) { + const varName = jsVarName(entry.from, entry.binding); + if (!seenVars.has(varName)) { + seenVars.add(varName); + imports.push(`import * as ${varName} from '${entry.from}';`); + } + const field = entry.field ?? 'templateId'; + fields.push( + ` readonly ${entryName}: typeof ${varName}.${entry.binding}.${field};` + ); + } + declarations.push( + `export declare const ${constName}: {\n${fields.join('\n')}\n};` + ); + } + + return { imports, declarations }; +} + +function writeRootIndexFiles( + destLib: string, + namespaces: string[], + templateConstants?: RootIndexConfig['templateConstants'] +): void { + const constants = templateConstants ?? {}; + const jsConstants = renderTemplateConstantsJs(constants); + const dtsConstants = renderTemplateConstantsDts(constants); + + const namespaceRequires = namespaces + .map( + (ns) => + `var ${ns} = require('./${ns}');\nexports.${ns} = ${ns};` + ) + .join('\n'); + + const indexJs = `"use strict"; +/* eslint-disable-next-line no-unused-vars */ +function __export(m) { +/* eslint-disable-next-line no-prototype-builtins */ + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +Object.defineProperty(exports, "__esModule", { value: true }); +${namespaceRequires} +${jsConstants.requires.join('\n')} +${jsConstants.exports.join('\n')} +`; + + const namespaceImports = namespaces + .map((ns) => `import * as ${ns} from './${ns}';`) + .join('\n'); + const namespaceExport = `export { ${namespaces.join(', ')} };`; + + const indexDts = `${namespaceImports} +${dtsConstants.imports.join('\n')} +${namespaceExport} +${dtsConstants.declarations.join('\n')} +`; + + fs.writeFileSync(path.join(destLib, 'index.js'), indexJs); + fs.writeFileSync(path.join(destLib, 'index.d.ts'), indexDts); +} + +/** + * Patch daml.js / @fairmint / @daml.js imports onto `__bundled__` wrappers + * using the same rewrite rules as bundle-dependencies. + */ +export function patchBundledDependencyImports( + destLib: string, + options: { + generatedJsDir: string; + pins: ResolvedDamlJsBundleConfig['pins']; + presets: BundlePresetId[]; + } +): number { + // Rewrite rules are resolved relative to a package root with lib/ child. + // destLib is the lib directory itself, so the synthetic package root is its parent. + const packageRoot = path.dirname(destLib); + const rules = options.presets.flatMap((id) => + BUNDLE_PRESETS[id].rewriteRules(packageRoot, options.pins) + ); + if (rules.length === 0) { + return 0; + } + return applyGeneratedImportRewrites(destLib, rules); +} + +export function createRootIndex(options: CreateRootIndexOptions): { + config: ResolvedDamlJsBundleConfig; + sourcePackage: CodegenPackageConfig; + outputDir: string; +} { + const config = resolveDamlJsBundleConfig({ + rootDir: options.rootDir, + configPath: options.configPath, + }); + + if (!config.rootIndex) { + throw new Error( + `daml-js bundle config at ${config.configPath ?? config.rootDir} is missing rootIndex` + ); + } + + const rootIndex = config.rootIndex; + const packages = discoverCodegenPackages({ + rootDir: config.rootDir, + generatedJsRoot: config.generatedJsDir, + }); + const sourcePackage = resolveSourcePackage(packages, rootIndex.sourcePackage); + const pkgLib = sourcePackage.absoluteGeneratedLibDir; + if (!fs.existsSync(pkgLib)) { + throw new Error( + `Source package lib not found at ${pkgLib}. Run codegen-js + bundle-dependencies first.` + ); + } + + const outputRel = rootIndex.outputDir ?? 'lib'; + const destLib = path.join(config.rootDir, outputRel); + console.log(`🧩 Building combined ${outputRel}/ from ${sourcePackage.name} codegen...`); + removeDirectoryIfExists(destLib); + createDirectoryIfNotExists(destLib); + + for (const entry of rootIndex.copy) { + copyDirectory(path.join(pkgLib, entry), path.join(destLib, entry)); + } + + writeRootIndexFiles(destLib, rootIndex.namespaces, rootIndex.templateConstants); + + const postPresets = rootIndex.postBundlePresets ?? []; + if (postPresets.length > 0) { + // Preset apply paths assume targetDir/lib/… — use repo root when outputDir is `lib`. + const packageRootForPresets = + path.basename(destLib) === 'lib' ? path.dirname(destLib) : destLib; + applyBundlePresets({ + targetDir: packageRootForPresets, + generatedJsDir: config.absoluteGeneratedJsDir, + pins: config.pins, + presets: postPresets, + }); + } + + const shouldPatch = rootIndex.patchBundledImports ?? true; + if (shouldPatch) { + patchBundledDependencyImports(destLib, { + generatedJsDir: config.absoluteGeneratedJsDir, + pins: config.pins, + presets: [...new Set([...config.presets, ...postPresets])], + }); + } + + const packageRootForIndexes = + path.basename(destLib) === 'lib' ? path.dirname(destLib) : destLib; + ensureBundledDANamespaceIndexes(packageRootForIndexes); + ensureBundledSpliceNamespaceIndexes(packageRootForIndexes); + + console.log(`✅ Combined ${outputRel}/ created`); + return { config, sourcePackage, outputDir: destLib }; +} + +export function runCreateRootIndexCli(args: string[]): void { + try { + const rootDir = path.resolve(parseFlagValue(args, '--root') ?? process.cwd()); + const configPath = parseFlagValue(args, '--config'); + createRootIndex({ + rootDir, + ...(configPath ? { configPath } : {}), + }); + } catch (error) { + console.error(`❌ Error during create-root-index: ${getErrorMessage(error)}`); + process.exit(1); + } +} diff --git a/src/daml/codegen/daml-js-bundle-config.ts b/src/daml/codegen/daml-js-bundle-config.ts new file mode 100644 index 0000000..792184c --- /dev/null +++ b/src/daml/codegen/daml-js-bundle-config.ts @@ -0,0 +1,381 @@ +/** + * Consumer config for Phase 2 DAML→JS bundling / root-index merge. + * + * Resolution order: + * 1. `--config` / `options.configPath` + * 2. `package.json` → `cantonDevTools.damlJsBundle` (path string or inline object) + * 3. repo-root `daml-js-bundle.json` + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + assertSafeRelativePath, + normalizeRelativePath, + resolveContainedPath, +} from '../sync-splice-dars'; +import type { PackageJson } from '../types'; +import { BUNDLE_PRESET_IDS, type BundlePresetId } from './bundle-presets'; + +export const DAML_JS_BUNDLE_CONFIG_FILENAME = 'daml-js-bundle.json'; + +export interface DamlJsBundlePins { + /** splice-amulet version (default `0.1.19`). */ + amulet?: string; + /** splice-token-standard-utils version (default `2.0.0`). */ + tokenStandardUtils?: string; +} + +export interface RootIndexTemplateEntry { + /** Relative module path from output lib (e.g. `./WrappedAssets/Holding/module`). */ + from: string; + /** Exported binding on that module (e.g. `WrappedAsset`). */ + binding: string; + /** Property to expose (default `templateId`). */ + field?: string; +} + +export interface RootIndexSourcePackage { + /** Exact daml.yaml package name. */ + name?: string; + /** Match packages whose name starts with this prefix. */ + namePrefix?: string; + /** Discover key (source dir basename, lowercase). */ + key?: string; +} + +export interface RootIndexConfig { + /** Output directory relative to repo root (default `lib`). */ + outputDir?: string; + /** Which codegen package supplies the primary tree. */ + sourcePackage: RootIndexSourcePackage; + /** Top-level dirs to copy from the source package `lib/` into `outputDir`. */ + copy: string[]; + /** Namespace exports written into `outputDir/index.{js,d.ts}` (order preserved). */ + namespaces: string[]; + /** + * Optional frozen template-id constant maps written into the root index. + * Keys are export names (e.g. `WRAPPED_ASSETS_TEMPLATES`). + */ + templateConstants?: Record>; + /** + * Presets to force-apply after the merge (target = repo root). + * Useful when the merged tree needs token/DA modules not present on the source package alone. + */ + postBundlePresets?: BundlePresetId[]; + /** Rewrite remaining daml.js/@fairmint imports onto `__bundled__` (default true). */ + patchBundledImports?: boolean; +} + +export interface DamlJsBundleConfigFile { + /** Relative generated JS root (default `generated/js`). */ + generatedJsDir?: string; + /** + * Presets to consider when bundling each codegen package. + * `da-internal-template` is always applied even if omitted. + * Default: all built-in stdlib/Splice presets. + */ + presets?: BundlePresetId[]; + /** Version pins for floating Splice packages. */ + pins?: DamlJsBundlePins; + /** Merged published `lib/` configuration (create-root-index). */ + rootIndex?: RootIndexConfig; +} + +export interface ResolvedDamlJsBundleConfig { + rootDir: string; + configPath: string | null; + generatedJsDir: string; + absoluteGeneratedJsDir: string; + presets: BundlePresetId[]; + pins: Required; + rootIndex: RootIndexConfig | null; +} + +const DEFAULT_PINS: Required = { + amulet: '0.1.19', + tokenStandardUtils: '2.0.0', +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertPresetId(value: unknown, label: string): BundlePresetId { + if (typeof value !== 'string' || !(BUNDLE_PRESET_IDS as readonly string[]).includes(value)) { + throw new Error( + `Invalid ${label}: ${JSON.stringify(value)}. Expected one of: ${BUNDLE_PRESET_IDS.join(', ')}` + ); + } + return value as BundlePresetId; +} + +function parsePins(raw: unknown, label: string): DamlJsBundlePins { + if (raw === undefined) return {}; + if (!isRecord(raw)) { + throw new Error(`Invalid ${label} (expected object)`); + } + const pins: DamlJsBundlePins = {}; + if (raw['amulet'] !== undefined) { + if (typeof raw['amulet'] !== 'string' || raw['amulet'].length === 0) { + throw new Error(`Invalid ${label}.amulet (expected non-empty string)`); + } + pins.amulet = raw['amulet']; + } + if (raw['tokenStandardUtils'] !== undefined) { + if (typeof raw['tokenStandardUtils'] !== 'string' || raw['tokenStandardUtils'].length === 0) { + throw new Error(`Invalid ${label}.tokenStandardUtils (expected non-empty string)`); + } + pins.tokenStandardUtils = raw['tokenStandardUtils']; + } + for (const key of Object.keys(raw)) { + if (key !== 'amulet' && key !== 'tokenStandardUtils') { + throw new Error(`Unknown ${label} key: ${key}`); + } + } + return pins; +} + +function parseTemplateConstants( + raw: unknown, + label: string +): RootIndexConfig['templateConstants'] { + if (raw === undefined) return undefined; + if (!isRecord(raw)) { + throw new Error(`Invalid ${label} (expected object)`); + } + const result: NonNullable = {}; + for (const [constName, entriesRaw] of Object.entries(raw)) { + if (!isRecord(entriesRaw)) { + throw new Error(`Invalid ${label}.${constName} (expected object)`); + } + const entries: Record = {}; + for (const [entryName, entryRaw] of Object.entries(entriesRaw)) { + if (!isRecord(entryRaw)) { + throw new Error(`Invalid ${label}.${constName}.${entryName} (expected object)`); + } + const from = entryRaw['from']; + const binding = entryRaw['binding']; + const field = entryRaw['field']; + if (typeof from !== 'string' || from.length === 0) { + throw new Error(`Invalid ${label}.${constName}.${entryName}.from`); + } + if (typeof binding !== 'string' || binding.length === 0) { + throw new Error(`Invalid ${label}.${constName}.${entryName}.binding`); + } + if (field !== undefined && (typeof field !== 'string' || field.length === 0)) { + throw new Error(`Invalid ${label}.${constName}.${entryName}.field`); + } + entries[entryName] = { + from, + binding, + ...(typeof field === 'string' ? { field } : {}), + }; + } + result[constName] = entries; + } + return result; +} + +function parseRootIndex(raw: unknown, label: string): RootIndexConfig { + if (!isRecord(raw)) { + throw new Error(`Invalid ${label} (expected object)`); + } + const sourcePackageRaw = raw['sourcePackage']; + if (!isRecord(sourcePackageRaw)) { + throw new Error(`Invalid ${label}.sourcePackage (expected object)`); + } + const sourcePackage: RootIndexSourcePackage = {}; + for (const key of ['name', 'namePrefix', 'key'] as const) { + const value = sourcePackageRaw[key]; + if (value === undefined) continue; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid ${label}.sourcePackage.${key}`); + } + sourcePackage[key] = value; + } + if (!sourcePackage.name && !sourcePackage.namePrefix && !sourcePackage.key) { + throw new Error( + `${label}.sourcePackage requires at least one of name, namePrefix, or key` + ); + } + + const copyRaw = raw['copy']; + if (!Array.isArray(copyRaw) || copyRaw.length === 0 || !copyRaw.every((v) => typeof v === 'string')) { + throw new Error(`Invalid ${label}.copy (expected non-empty string[])`); + } + const namespacesRaw = raw['namespaces']; + if ( + !Array.isArray(namespacesRaw) || + namespacesRaw.length === 0 || + !namespacesRaw.every((v) => typeof v === 'string') + ) { + throw new Error(`Invalid ${label}.namespaces (expected non-empty string[])`); + } + + const outputDir = raw['outputDir']; + if (outputDir !== undefined && (typeof outputDir !== 'string' || outputDir.length === 0)) { + throw new Error(`Invalid ${label}.outputDir`); + } + + const postBundlePresetsRaw = raw['postBundlePresets']; + let postBundlePresets: BundlePresetId[] | undefined; + if (postBundlePresetsRaw !== undefined) { + if (!Array.isArray(postBundlePresetsRaw)) { + throw new Error(`Invalid ${label}.postBundlePresets (expected array)`); + } + postBundlePresets = postBundlePresetsRaw.map((id, index) => + assertPresetId(id, `${label}.postBundlePresets[${index}]`) + ); + } + + const patchBundledImports = raw['patchBundledImports']; + if (patchBundledImports !== undefined && typeof patchBundledImports !== 'boolean') { + throw new Error(`Invalid ${label}.patchBundledImports (expected boolean)`); + } + + return { + ...(typeof outputDir === 'string' ? { outputDir } : {}), + sourcePackage, + copy: copyRaw as string[], + namespaces: namespacesRaw as string[], + templateConstants: parseTemplateConstants(raw['templateConstants'], `${label}.templateConstants`), + ...(postBundlePresets ? { postBundlePresets } : {}), + ...(typeof patchBundledImports === 'boolean' ? { patchBundledImports } : {}), + }; +} + +export function parseDamlJsBundleConfig(raw: unknown, label: string): DamlJsBundleConfigFile { + if (!isRecord(raw)) { + throw new Error(`Invalid ${label} (expected object)`); + } + + const generatedJsDir = raw['generatedJsDir']; + if (generatedJsDir !== undefined && (typeof generatedJsDir !== 'string' || generatedJsDir.length === 0)) { + throw new Error(`Invalid ${label}.generatedJsDir`); + } + + let presets: BundlePresetId[] | undefined; + if (raw['presets'] !== undefined) { + if (!Array.isArray(raw['presets'])) { + throw new Error(`Invalid ${label}.presets (expected array)`); + } + presets = raw['presets'].map((id, index) => assertPresetId(id, `${label}.presets[${index}]`)); + } + + const rootIndex = + raw['rootIndex'] === undefined + ? undefined + : parseRootIndex(raw['rootIndex'], `${label}.rootIndex`); + + return { + ...(typeof generatedJsDir === 'string' ? { generatedJsDir } : {}), + ...(presets ? { presets } : {}), + pins: parsePins(raw['pins'], `${label}.pins`), + ...(rootIndex ? { rootIndex } : {}), + }; +} + +function readJsonFile(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; +} + +/** + * Resolve which config file (or inline package.json object) to load. + * Returns null when nothing is configured (callers may use defaults). + */ +export function resolveDamlJsBundleConfigSource( + rootDir: string, + configPath?: string +): { kind: 'file'; path: string } | { kind: 'inline'; value: unknown; path: string } | null { + if (configPath) { + return { kind: 'file', path: path.resolve(configPath) }; + } + + const packageJsonPath = path.join(rootDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath) as PackageJson & { + cantonDevTools?: { damlJsBundle?: unknown }; + }; + const pointer = packageJson.cantonDevTools?.damlJsBundle; + if (typeof pointer === 'string' && pointer.length > 0) { + assertSafeRelativePath(pointer, 'package.json cantonDevTools.damlJsBundle'); + const resolved = resolveContainedPath( + rootDir, + normalizeRelativePath(pointer), + 'package.json cantonDevTools.damlJsBundle' + ); + return { kind: 'file', path: resolved }; + } + if (isRecord(pointer)) { + return { kind: 'inline', value: pointer, path: packageJsonPath }; + } + } + + const defaultPath = path.join(rootDir, DAML_JS_BUNDLE_CONFIG_FILENAME); + if (fs.existsSync(defaultPath)) { + return { kind: 'file', path: defaultPath }; + } + + return null; +} + +export function resolveDamlJsBundleConfig(options: { + rootDir: string; + configPath?: string; + /** When true, missing config uses built-in defaults instead of throwing. */ + allowMissing?: boolean; +}): ResolvedDamlJsBundleConfig { + const rootDir = path.resolve(options.rootDir); + const source = resolveDamlJsBundleConfigSource(rootDir, options.configPath); + + let parsed: DamlJsBundleConfigFile = {}; + let configPath: string | null = null; + + if (source === null) { + if (!options.allowMissing) { + throw new Error( + `No daml-js bundle config found under ${rootDir}. ` + + `Add ${DAML_JS_BUNDLE_CONFIG_FILENAME}, set package.json cantonDevTools.damlJsBundle, or pass --config.` + ); + } + } else if (source.kind === 'file') { + if (!fs.existsSync(source.path)) { + throw new Error(`daml-js bundle config not found: ${source.path}`); + } + configPath = source.path; + parsed = parseDamlJsBundleConfig(readJsonFile(source.path), source.path); + } else { + configPath = source.path; + parsed = parseDamlJsBundleConfig(source.value, `${source.path} cantonDevTools.damlJsBundle`); + } + + const generatedJsDir = parsed.generatedJsDir ?? 'generated/js'; + assertSafeRelativePath(generatedJsDir, 'generatedJsDir'); + const absoluteGeneratedJsDir = resolveContainedPath(rootDir, generatedJsDir, 'generatedJsDir'); + + const presets = parsed.presets ?? [...BUNDLE_PRESET_IDS]; + // Always include da-internal-template first. + const orderedPresets: BundlePresetId[] = []; + if (!presets.includes('da-internal-template')) { + orderedPresets.push('da-internal-template'); + } + for (const id of presets) { + if (!orderedPresets.includes(id)) { + orderedPresets.push(id); + } + } + + return { + rootDir, + configPath, + generatedJsDir, + absoluteGeneratedJsDir, + presets: orderedPresets, + pins: { + amulet: parsed.pins?.amulet ?? DEFAULT_PINS.amulet, + tokenStandardUtils: parsed.pins?.tokenStandardUtils ?? DEFAULT_PINS.tokenStandardUtils, + }, + rootIndex: parsed.rootIndex ?? null, + }; +} diff --git a/src/daml/codegen/index.ts b/src/daml/codegen/index.ts index 25a71ca..920713c 100644 --- a/src/daml/codegen/index.ts +++ b/src/daml/codegen/index.ts @@ -1,4 +1,4 @@ -/** Generic DAML → npm JS bindings helpers (Phase 1). */ +/** Generic DAML → npm JS bindings helpers (Phase 1 + Phase 2). */ export * from './generated-output-helpers'; export * from './generated-package-index'; @@ -10,3 +10,8 @@ export * from './fix-splice-refs'; export * from './verify-package-imports'; export * from './discover-codegen-packages'; export * from './codegen-js'; +export * from './daml-js-bundle-config'; +export * from './bundle-presets'; +export * from './bundle-fs'; +export * from './bundle-dependencies'; +export * from './create-root-index'; diff --git a/test/unit/daml/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts new file mode 100644 index 0000000..27f4217 --- /dev/null +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -0,0 +1,326 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + bundleDependenciesForTarget, + createRootIndex, + parseDamlJsBundleConfig, + resolveDamlJsBundleConfig, + BUNDLE_PRESET_IDS, +} from '../../../src/daml/codegen'; + +function writePair(dir: string, base: string, js: string, dts = 'export {};\n'): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${base}.js`), js); + writeFileSync(join(dir, `${base}.d.ts`), dts); +} + +function scaffoldRepo(): string { + const root = mkdtempSync(join(tmpdir(), 'daml-js-bundle-')); + writeFileSync( + join(root, 'multi-package.yaml'), + `packages:\n- DemoPkg\n` + ); + mkdirSync(join(root, 'DemoPkg'), { recursive: true }); + writeFileSync( + join(root, 'DemoPkg', 'daml.yaml'), + `name: DemoPkg +version: 0.0.1 +source: daml +dependencies: +- daml-prim +- daml-stdlib +codegen: + js: + output-directory: ../generated/js/DemoPkg-0.0.1 +` + ); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@fairmint/demo-daml-js', version: '0.0.1' }, null, 2) + ); + return root; +} + +function scaffoldGeneratedPackage(root: string): string { + const pkgDir = join(root, 'generated', 'js', 'DemoPkg-0.0.1'); + const libDir = join(pkgDir, 'lib'); + writePair( + join(libDir, 'Demo'), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var template = require('daml.js/ghc-stdlib-DA-Internal-Template-1.0.0'); +var time = require('daml.js/daml-stdlib-DA-Time-Types-1.0.0'); +exports.Demo = { template: template, time: time }; +` + ); + writePair( + libDir, + 'index', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var Demo = require('./Demo'); +exports.Demo = Demo; +`, + `import * as Demo from './Demo'; +export { Demo } ; +` + ); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify( + { + name: '@fairmint/demo-daml-js', + version: '0.0.1', + dependencies: { + 'daml.js/ghc-stdlib-DA-Internal-Template-1.0.0': 'file:../ghc-stdlib-DA-Internal-Template-1.0.0', + 'daml.js/daml-stdlib-DA-Time-Types-1.0.0': 'file:../daml-stdlib-DA-Time-Types-1.0.0', + }, + }, + null, + 4 + ) + ); + return pkgDir; +} + +function scaffoldDependencyTemplates(root: string): void { + const jsRoot = join(root, 'generated', 'js'); + + writePair( + join(jsRoot, 'ghc-stdlib-DA-Internal-Template-1.0.0', 'lib', 'DA', 'Internal', 'Template'), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Archive = { encode: function () { return {}; } }; +`, + `export declare const Archive: { encode: () => object };\n` + ); + + writePair( + join(jsRoot, 'daml-stdlib-DA-Time-Types-1.0.0', 'lib', 'DA', 'Time', 'Types'), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RelTime = {}; +`, + `export declare const RelTime: object;\n` + ); + writePair( + join(jsRoot, 'daml-stdlib-DA-Time-Types-1.0.0', 'lib', 'DA', 'Time', 'Types'), + 'index', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function __export(m) { for (var p in m) exports[p] = m[p]; } +__export(require('./module')); +`, + `export * from './module';\n` + ); +} + +describe('daml-js-bundle config', (): void => { + it('parses presets, pins, and rootIndex', (): void => { + const parsed = parseDamlJsBundleConfig( + { + presets: ['da-internal-template', 'amulet', 'da-time-types'], + pins: { amulet: '0.1.19' }, + rootIndex: { + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo', 'DA'], + namespaces: ['Demo', 'DA'], + templateConstants: { + DEMO_TEMPLATES: { + demo: { + from: './Demo/module', + binding: 'Demo', + }, + }, + }, + postBundlePresets: ['da-time-types'], + }, + }, + 'test' + ); + expect(parsed.presets).toEqual(['da-internal-template', 'amulet', 'da-time-types']); + expect(parsed.pins?.amulet).toBe('0.1.19'); + expect(parsed.rootIndex?.namespaces).toEqual(['Demo', 'DA']); + }); + + it('rejects unknown preset ids', (): void => { + expect(() => + parseDamlJsBundleConfig({ presets: ['wrapped-assets'] }, 'test') + ).toThrow(/Invalid test.presets\[0\]/); + }); + + it('resolves daml-js-bundle.json with defaults', (): void => { + const root = mkdtempSync(join(tmpdir(), 'bundle-config-')); + try { + writeFileSync( + join(root, 'daml-js-bundle.json'), + JSON.stringify({ + presets: ['da-internal-template', 'da-time-types'], + pins: { amulet: '0.1.20' }, + }) + ); + const resolved = resolveDamlJsBundleConfig({ rootDir: root }); + expect(resolved.pins.amulet).toBe('0.1.20'); + expect(resolved.pins.tokenStandardUtils).toBe('2.0.0'); + expect(resolved.presets[0]).toBe('da-internal-template'); + expect(resolved.presets).toContain('da-time-types'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('defaults to all built-in presets when allowMissing', (): void => { + const root = mkdtempSync(join(tmpdir(), 'bundle-missing-')); + try { + const resolved = resolveDamlJsBundleConfig({ rootDir: root, allowMissing: true }); + expect(resolved.presets).toEqual([...BUNDLE_PRESET_IDS]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('bundleDependenciesForTarget', (): void => { + it('bundles detected stdlib deps and rewrites imports', (): void => { + const root = scaffoldRepo(); + try { + scaffoldDependencyTemplates(root); + const pkgDir = scaffoldGeneratedPackage(root); + + const applied = bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['da-internal-template', 'da-time-types'], + }); + + expect(applied).toEqual(['da-internal-template', 'da-time-types']); + expect(existsSync(join(pkgDir, 'lib/DA/Internal/Template/module.js'))).toBe(true); + expect(existsSync(join(pkgDir, 'lib/__bundled__/ghc-stdlib-DA-Internal-Template/index.js'))).toBe( + true + ); + expect(existsSync(join(pkgDir, 'lib/DA/Time/Types/module.js'))).toBe(true); + + const demoJs = readFileSync(join(pkgDir, 'lib/Demo/module.js'), 'utf8'); + expect(demoJs).toContain('__bundled__/ghc-stdlib-DA-Internal-Template'); + expect(demoJs).toContain('__bundled__/daml-stdlib-DA-Time-Types'); + expect(demoJs).not.toContain('daml.js/ghc-stdlib-DA-Internal-Template-1.0.0'); + + const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + expect(pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0']).toBeUndefined(); + expect(pkgJson.dependencies?.['daml.js/daml-stdlib-DA-Time-Types-1.0.0']).toBeUndefined(); + + const mainIndex = readFileSync(join(pkgDir, 'lib/index.js'), 'utf8'); + expect(mainIndex).toContain("require('./DA')"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not enable featured-app-v2 without amulet', (): void => { + const root = scaffoldRepo(); + try { + scaffoldDependencyTemplates(root); + const pkgDir = scaffoldGeneratedPackage(root); + // Place an amulet template that references v2 — must NOT trigger without willBundleAmulet. + writePair( + join(root, 'generated/js/splice-amulet-0.1.19/lib/Splice/Amulet'), + 'module', + `"use strict"; +require('daml.js/splice-api-featured-app-v2-1.0.0'); +` + ); + + const applied = bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['da-internal-template', 'featured-app-v2', 'amulet'], + }); + + expect(applied).toEqual(['da-internal-template']); + expect(applied).not.toContain('featured-app-v2'); + expect(applied).not.toContain('amulet'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('createRootIndex', (): void => { + it('merges configured namespaces and template constants', (): void => { + const root = scaffoldRepo(); + try { + scaffoldDependencyTemplates(root); + const pkgDir = scaffoldGeneratedPackage(root); + bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['da-internal-template', 'da-time-types'], + }); + + // Add a Demo binding with templateId for constant generation. + writePair( + join(pkgDir, 'lib', 'Demo'), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Demo = { templateId: 'Demo:Demo:Demo' }; +`, + `export declare const Demo: { templateId: string };\n` + ); + + writeFileSync( + join(root, 'daml-js-bundle.json'), + JSON.stringify( + { + presets: ['da-internal-template', 'da-time-types'], + rootIndex: { + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo', 'DA', '__bundled__'], + namespaces: ['Demo', 'DA'], + templateConstants: { + DEMO_TEMPLATES: { + demo: { from: './Demo/module', binding: 'Demo' }, + }, + }, + postBundlePresets: ['da-time-types'], + }, + }, + null, + 2 + ) + ); + + const result = createRootIndex({ rootDir: root }); + expect(result.outputDir).toBe(join(root, 'lib')); + expect(existsSync(join(root, 'lib/Demo/module.js'))).toBe(true); + expect(existsSync(join(root, 'lib/DA/Internal/Template/module.js'))).toBe(true); + + const indexJs = readFileSync(join(root, 'lib/index.js'), 'utf8'); + expect(indexJs).toContain("require('./Demo')"); + expect(indexJs).toContain('DEMO_TEMPLATES'); + expect(indexJs).toContain('Demo.templateId'); + + const indexDts = readFileSync(join(root, 'lib/index.d.ts'), 'utf8'); + expect(indexDts).toContain('export { Demo, DA }'); + expect(indexDts).toContain('DEMO_TEMPLATES'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 802a37c638faf55d45b60647d01279c8f68e5881 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 12:34:04 +0000 Subject: [PATCH 05/12] fix: address Copilot and Bugbot review on daml-js codegen tooling - Rewrite both single- and double-quoted generated imports - Collapse manifest entries only when .js/.d.ts pairs both exist - Fail codegen-js when a discovered package lacks generated output - Strict x.y.z validation, dotted repo URL parsing, fail-closed npm lookup, v-semver git describe, and execFileSync for git log ranges - Validate rootIndex outputDir/copy with assertSafeRelativePath and require basename lib for bundle rewrite layout Co-authored-by: hardlydiff --- scripts/prepare-release.ts | 7 +- src/daml/codegen/codegen-js.ts | 17 +- src/daml/codegen/collapse-manifest.ts | 19 +- src/daml/codegen/create-root-index.ts | 50 ++-- src/daml/codegen/daml-js-bundle-config.ts | 31 ++- src/daml/codegen/generated-output-helpers.ts | 10 +- src/prepare-release.ts | 236 +++++++++++++------ test/unit/daml/bundle-dependencies.test.ts | 58 +++++ test/unit/daml/codegen.test.ts | 54 ++++- test/unit/scripts/prepare-release.test.ts | 13 +- 10 files changed, 397 insertions(+), 98 deletions(-) diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 8017a92..09a69bd 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -16,4 +16,9 @@ if (require.main === module) { } } -export { prepareRelease, selectReleaseVersion } from '../src/prepare-release'; +export { + parseChangelogRepo, + parseVersion, + prepareRelease, + selectReleaseVersion, +} from '../src/prepare-release'; diff --git a/src/daml/codegen/codegen-js.ts b/src/daml/codegen/codegen-js.ts index 5d76d8d..86610f0 100644 --- a/src/daml/codegen/codegen-js.ts +++ b/src/daml/codegen/codegen-js.ts @@ -92,9 +92,17 @@ export function runCodegenJs(options: CodegenJsOptions): CodegenJsResult { } const suffixes = readCodegenPublishSuffixes(rootDir); - const updateTargets = packages - .filter((pkg) => fs.existsSync(path.join(pkg.absoluteGeneratedJsDir, 'package.json'))) - .map((pkg) => ({ + const updateTargets = packages.map((pkg) => { + const generatedPackageJson = path.join(pkg.absoluteGeneratedJsDir, 'package.json'); + if (!fs.existsSync(generatedPackageJson)) { + throw new Error( + `Missing generated package.json for ${pkg.name} at ${generatedPackageJson}` + + (options.skipDpm + ? ' (--skip-dpm requires prior dpm codegen-js output for every discovered package).' + : '.') + ); + } + return { dir: pkg.absoluteGeneratedJsDir, publishedPackageName: resolvePublishedPackageName({ rootPackageName: rootPackage.name!, @@ -102,7 +110,8 @@ export function runCodegenJs(options: CodegenJsOptions): CodegenJsResult { suffixes, codegenPackageCount: packages.length, }), - })); + }; + }); const updatedDirs = updateGeneratedPackagesFromRoot({ rootDir, diff --git a/src/daml/codegen/collapse-manifest.ts b/src/daml/codegen/collapse-manifest.ts index 3797693..00ddeee 100644 --- a/src/daml/codegen/collapse-manifest.ts +++ b/src/daml/codegen/collapse-manifest.ts @@ -22,8 +22,23 @@ export function collapseManifestLines(lines: readonly string[]): string[] { } for (const file of filesToKeep) { - if (file.endsWith('.d.ts') || file.endsWith('.js')) { - collapsedFiles.add(file.replace(/\.(d\.ts|js)$/, '')); + if (file.endsWith('.d.ts')) { + const base = file.slice(0, -'.d.ts'.length); + const jsCounterpart = `${base}.js`; + // Only collapse when the matching .js / .d.ts pair both exist. + if (filesToKeep.has(jsCounterpart)) { + collapsedFiles.add(base); + } else { + collapsedFiles.add(file); + } + } else if (file.endsWith('.js')) { + const base = file.slice(0, -'.js'.length); + const dtsCounterpart = `${base}.d.ts`; + if (filesToKeep.has(dtsCounterpart)) { + collapsedFiles.add(base); + } else { + collapsedFiles.add(file); + } } else { collapsedFiles.add(file); } diff --git a/src/daml/codegen/create-root-index.ts b/src/daml/codegen/create-root-index.ts index e8f046c..d1a3d2f 100644 --- a/src/daml/codegen/create-root-index.ts +++ b/src/daml/codegen/create-root-index.ts @@ -8,6 +8,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseFlagValue } from '../packages'; +import { + assertSafeRelativePath, + normalizeRelativePath, + resolveContainedPath, +} from '../sync-splice-dars'; import { getErrorMessage } from '../types'; import { applyBundlePresets } from './bundle-dependencies'; import { @@ -166,6 +171,9 @@ ${dtsConstants.declarations.join('\n')} /** * Patch daml.js / @fairmint / @daml.js imports onto `__bundled__` wrappers * using the same rewrite rules as bundle-dependencies. + * + * `destLib` must be a directory named `lib`: preset rewrite rules resolve + * targets under `/lib/…`, so the package root is `dirname(destLib)`. */ export function patchBundledDependencyImports( destLib: string, @@ -175,8 +183,12 @@ export function patchBundledDependencyImports( presets: BundlePresetId[]; } ): number { - // Rewrite rules are resolved relative to a package root with lib/ child. - // destLib is the lib directory itself, so the synthetic package root is its parent. + if (path.basename(destLib) !== 'lib') { + throw new Error( + `patchBundledDependencyImports expects a directory named "lib" (got ${destLib}). ` + + 'Bundle rewrite rules resolve targets under /lib/.' + ); + } const packageRoot = path.dirname(destLib); const rules = options.presets.flatMap((id) => BUNDLE_PRESETS[id].rewriteRules(packageRoot, options.pins) @@ -216,25 +228,37 @@ export function createRootIndex(options: CreateRootIndexOptions): { ); } - const outputRel = rootIndex.outputDir ?? 'lib'; - const destLib = path.join(config.rootDir, outputRel); + const outputRel = normalizeRelativePath(rootIndex.outputDir ?? 'lib'); + assertSafeRelativePath(outputRel, 'rootIndex.outputDir'); + if (path.basename(outputRel) !== 'lib') { + throw new Error( + `rootIndex.outputDir must resolve to a directory named "lib" (got ${JSON.stringify(outputRel)}). ` + + 'Bundle presets and import rewrites assume a /lib layout.' + ); + } + const destLib = resolveContainedPath(config.rootDir, outputRel, 'rootIndex.outputDir'); + const packageRoot = path.dirname(destLib); + console.log(`🧩 Building combined ${outputRel}/ from ${sourcePackage.name} codegen...`); removeDirectoryIfExists(destLib); createDirectoryIfNotExists(destLib); - for (const entry of rootIndex.copy) { - copyDirectory(path.join(pkgLib, entry), path.join(destLib, entry)); + for (const [index, entry] of rootIndex.copy.entries()) { + assertSafeRelativePath(entry, `rootIndex.copy[${index}]`); + const normalizedEntry = normalizeRelativePath(entry); + copyDirectory( + resolveContainedPath(pkgLib, normalizedEntry, `rootIndex.copy[${index}]`), + resolveContainedPath(destLib, normalizedEntry, `rootIndex.copy[${index}]`) + ); } writeRootIndexFiles(destLib, rootIndex.namespaces, rootIndex.templateConstants); const postPresets = rootIndex.postBundlePresets ?? []; if (postPresets.length > 0) { - // Preset apply paths assume targetDir/lib/… — use repo root when outputDir is `lib`. - const packageRootForPresets = - path.basename(destLib) === 'lib' ? path.dirname(destLib) : destLib; + // Preset apply paths assume targetDir/lib/…; packageRoot is dirname(destLib). applyBundlePresets({ - targetDir: packageRootForPresets, + targetDir: packageRoot, generatedJsDir: config.absoluteGeneratedJsDir, pins: config.pins, presets: postPresets, @@ -250,10 +274,8 @@ export function createRootIndex(options: CreateRootIndexOptions): { }); } - const packageRootForIndexes = - path.basename(destLib) === 'lib' ? path.dirname(destLib) : destLib; - ensureBundledDANamespaceIndexes(packageRootForIndexes); - ensureBundledSpliceNamespaceIndexes(packageRootForIndexes); + ensureBundledDANamespaceIndexes(packageRoot); + ensureBundledSpliceNamespaceIndexes(packageRoot); console.log(`✅ Combined ${outputRel}/ created`); return { config, sourcePackage, outputDir: destLib }; diff --git a/src/daml/codegen/daml-js-bundle-config.ts b/src/daml/codegen/daml-js-bundle-config.ts index 792184c..8aa848a 100644 --- a/src/daml/codegen/daml-js-bundle-config.ts +++ b/src/daml/codegen/daml-js-bundle-config.ts @@ -45,7 +45,11 @@ export interface RootIndexSourcePackage { } export interface RootIndexConfig { - /** Output directory relative to repo root (default `lib`). */ + /** + * Output directory relative to repo root (default `lib`). + * Must be a safe relative path whose basename is `lib` — bundle presets and + * import rewrites assume a `/lib` layout. + */ outputDir?: string; /** Which codegen package supplies the primary tree. */ sourcePackage: RootIndexSourcePackage; @@ -204,6 +208,11 @@ function parseRootIndex(raw: unknown, label: string): RootIndexConfig { if (!Array.isArray(copyRaw) || copyRaw.length === 0 || !copyRaw.every((v) => typeof v === 'string')) { throw new Error(`Invalid ${label}.copy (expected non-empty string[])`); } + const copy = (copyRaw as string[]).map((entry, index) => { + assertSafeRelativePath(entry, `${label}.copy[${index}]`); + return normalizeRelativePath(entry); + }); + const namespacesRaw = raw['namespaces']; if ( !Array.isArray(namespacesRaw) || @@ -213,9 +222,21 @@ function parseRootIndex(raw: unknown, label: string): RootIndexConfig { throw new Error(`Invalid ${label}.namespaces (expected non-empty string[])`); } - const outputDir = raw['outputDir']; - if (outputDir !== undefined && (typeof outputDir !== 'string' || outputDir.length === 0)) { - throw new Error(`Invalid ${label}.outputDir`); + const outputDirRaw = raw['outputDir']; + let outputDir: string | undefined; + if (outputDirRaw !== undefined) { + if (typeof outputDirRaw !== 'string' || outputDirRaw.length === 0) { + throw new Error(`Invalid ${label}.outputDir`); + } + assertSafeRelativePath(outputDirRaw, `${label}.outputDir`); + outputDir = normalizeRelativePath(outputDirRaw); + // Bundle presets / import rewrites resolve targets under `/lib/`. + if (path.basename(outputDir) !== 'lib') { + throw new Error( + `${label}.outputDir must resolve to a directory named "lib" ` + + `(got ${JSON.stringify(outputDirRaw)}). Custom non-lib output dirs are not supported.` + ); + } } const postBundlePresetsRaw = raw['postBundlePresets']; @@ -237,7 +258,7 @@ function parseRootIndex(raw: unknown, label: string): RootIndexConfig { return { ...(typeof outputDir === 'string' ? { outputDir } : {}), sourcePackage, - copy: copyRaw as string[], + copy, namespaces: namespacesRaw as string[], templateConstants: parseTemplateConstants(raw['templateConstants'], `${label}.templateConstants`), ...(postBundlePresets ? { postBundlePresets } : {}), diff --git a/src/daml/codegen/generated-output-helpers.ts b/src/daml/codegen/generated-output-helpers.ts index 1a243c8..f20933f 100644 --- a/src/daml/codegen/generated-output-helpers.ts +++ b/src/daml/codegen/generated-output-helpers.ts @@ -56,13 +56,17 @@ function replaceImportPath( isDts: boolean ): string { const escapedImportPath = escapeRegExp(importPath); + // Preserve whichever quote style the generated import used. if (isDts) { - return source.replace(new RegExp(`from '${escapedImportPath}';`, 'g'), `from '${relativePath}';`); + return source.replace( + new RegExp(`from (['"])${escapedImportPath}\\1;`, 'g'), + `from $1${relativePath}$1;` + ); } return source.replace( - new RegExp(`require\\('${escapedImportPath}'\\)`, 'g'), - `require('${relativePath}')` + new RegExp(`require\\((['"])${escapedImportPath}\\1\\)`, 'g'), + `require($1${relativePath}$1)` ); } diff --git a/src/prepare-release.ts b/src/prepare-release.ts index e8def1f..d677dd0 100644 --- a/src/prepare-release.ts +++ b/src/prepare-release.ts @@ -12,7 +12,7 @@ * Rewrites package.json version in the CI workspace only — does not commit it back. */ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseFlagValue } from './daml/packages'; @@ -34,10 +34,24 @@ export interface PrepareReleaseOptions { changelogRepo?: string; } +/** Exact `x.y.z` with no leading zeros, exponents, or empty segments. */ +const STRICT_SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +/** Release tags created by this workflow: `v` + exact semver. */ +const RELEASE_TAG_GLOB = 'v[0-9]*.[0-9]*.[0-9]*'; + +type NpmLookupResult = + | { kind: 'found'; latest: string | null; versions: Set } + | { kind: 'not-found' } + | { kind: 'error'; message: string }; + /** Check if a git tag exists */ function tagExists(rootDir: string, tag: string): boolean { try { - execSync(`git rev-parse "refs/tags/${tag}"`, { cwd: rootDir, stdio: 'ignore' }); + execFileSync('git', ['rev-parse', `refs/tags/${tag}`], { + cwd: rootDir, + stdio: 'ignore', + }); return true; } catch { return false; @@ -49,70 +63,132 @@ function encodePackageNameForRegistry(packageName: string): string { return packageName.replace('/', '%2f'); } +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + /** * Read published versions from the public registry HTTP API. * * Prefer this over `npm view` when a classic auth token in npmrc can 404 public * packages the token cannot read (npm reports that as 404, not 403). */ -function getNpmMetadataFromRegistry(packageName: string): { - latest: string | null; - versions: Set; -} | null { +export function getNpmMetadataFromRegistry(packageName: string): NpmLookupResult { try { const encodedName = encodePackageNameForRegistry(packageName); - const result = execSync(`curl -fsS "https://registry.npmjs.org/${encodedName}"`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - const metadata = JSON.parse(result) as { + const result = execFileSync( + 'curl', + ['-sS', '-w', '\n%{http_code}', `https://registry.npmjs.org/${encodedName}`], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + const trimmed = result.replace(/\s+$/, ''); + const lastNewline = trimmed.lastIndexOf('\n'); + const body = lastNewline === -1 ? '' : trimmed.slice(0, lastNewline); + const statusCode = lastNewline === -1 ? trimmed : trimmed.slice(lastNewline + 1); + + if (statusCode === '404') { + return { kind: 'not-found' }; + } + if (statusCode !== '200') { + return { kind: 'error', message: `registry.npmjs.org returned HTTP ${statusCode}` }; + } + + const metadata = JSON.parse(body) as { 'dist-tags'?: { latest?: string }; versions?: Record; }; const versions = new Set(Object.keys(metadata.versions ?? {})); const latest = metadata['dist-tags']?.latest ?? null; - return { latest, versions }; - } catch { - return null; + return { kind: 'found', latest, versions }; + } catch (error) { + return { kind: 'error', message: getErrorMessage(error) }; } } -/** Get all published versions from NPM registry */ -function getAllNpmVersions(packageName: string): Set { +function isNpmNotFoundMessage(message: string): boolean { + return /\b404\b|E404|Not Found|not found/i.test(message); +} + +/** Get published versions via `npm view` (fallback when registry HTTP fails transiently). */ +function getNpmMetadataFromNpmView(packageName: string): NpmLookupResult { try { - const result = execSync(`npm view "${packageName}" versions --json`, { + const versionsRaw = execSync(`npm view "${packageName}" versions --json`, { encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], }).trim(); - const versions = JSON.parse(result) as string | string[]; - if (Array.isArray(versions)) { - return new Set(versions); + const parsed = JSON.parse(versionsRaw) as string | string[]; + const versions = new Set(Array.isArray(parsed) ? parsed : [parsed]); + + let latest: string | null = null; + try { + const latestRaw = execSync(`npm view "${packageName}" version`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + latest = latestRaw || null; + } catch { + latest = versions.size > 0 ? [...versions].sort().at(-1) ?? null : null; } - return new Set([versions]); - } catch { - return new Set(); + + return { kind: 'found', latest, versions }; + } catch (error) { + const message = getErrorMessage(error); + if (isNpmNotFoundMessage(message)) { + return { kind: 'not-found' }; + } + return { kind: 'error', message }; } } -/** Get the latest version from NPM registry */ -function getLatestNpmVersion(packageName: string): string | null { - try { - const result = execSync(`npm view "${packageName}" version`, { encoding: 'utf8' }).trim(); - return result || null; - } catch { - return null; +/** + * Establish published-version state, failing closed when unknown. + * + * Registry HTTP 404 → treat as unpublished (new package). + * Registry HTTP success → use that metadata. + * Registry transient error → only accept a successful `npm view`; never treat + * ambiguous failures as "no versions published". + */ +export function resolvePublishedNpmState(packageName: string): { + latest: string | null; + versions: Set; +} { + const registry = getNpmMetadataFromRegistry(packageName); + if (registry.kind === 'found') { + return { latest: registry.latest, versions: registry.versions }; + } + if (registry.kind === 'not-found') { + return { latest: null, versions: new Set() }; } -} -/** Parse version string into components */ -function parseVersion(version: string): ParsedVersion | null { - const parts = version.split('.').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { - return null; + const npmView = getNpmMetadataFromNpmView(packageName); + if (npmView.kind === 'found') { + console.log( + `registry.npmjs.org unavailable (${registry.message}); using npm view metadata instead` + ); + return { latest: npmView.latest, versions: npmView.versions }; } - if (!parts.every((part) => Number.isInteger(part) && part >= 0)) { + + throw new Error( + `Unable to determine published versions for ${packageName} (failing closed). ` + + `registry: ${registry.message}; npm view: ${npmView.message}` + ); +} + +/** Parse version string into components (exact `x.y.z` only). */ +export function parseVersion(version: string): ParsedVersion | null { + const match = STRICT_SEMVER_PATTERN.exec(version); + if (!match) { return null; } - return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! }; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; } /** Compare two parsed semantic versions. */ @@ -158,6 +234,10 @@ export function selectReleaseVersion( } const npmParsed = latestNpmVersion ? parseVersion(latestNpmVersion) : null; + if (latestNpmVersion && !npmParsed) { + throw new Error(`Invalid version from npm: ${latestNpmVersion}. Expected format: x.y.z`); + } + const manifestAheadOfNpm = !npmParsed || compareVersions(manifestParsed, npmParsed) > 0; if (manifestAheadOfNpm && !isVersionTaken(manifestVersion)) { @@ -177,8 +257,12 @@ export function parseChangelogRepo( const url = typeof repository === 'string' ? repository : repository.url; if (!url) return undefined; - const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?/i); - return match?.[1]; + // Capture owner/repo (dots allowed); strip only a trailing `.git` suffix. + const match = url.match(/github\.com[/:]([^/]+\/[^/#?\s]+)/i); + if (!match?.[1]) { + return undefined; + } + return match[1].replace(/\.git$/i, ''); } export function resolveChangelogRepo( @@ -189,6 +273,40 @@ export function resolveChangelogRepo( return parseChangelogRepo(packageJson.repository); } +/** Describe the nearest prior `v` release tag, or null if none. */ +function describePreviousReleaseTag(rootDir: string): string | null { + try { + const lastTag = execFileSync( + 'git', + ['describe', '--tags', '--abbrev=0', '--match', RELEASE_TAG_GLOB], + { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + } + ).trim(); + return lastTag || null; + } catch { + return null; + } +} + +function readCommitsSince(rootDir: string, lastTag: string | null): string { + if (lastTag) { + return execFileSync('git', ['log', '--oneline', '--format=%s', `${lastTag}..HEAD`], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } + + return execFileSync('git', ['log', '--oneline', '--format=%s', '-n', '20'], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + /** * Prepare release by selecting version and generating changelog. * Safe for local testing (no git tag / push operations). @@ -208,23 +326,13 @@ export function prepareRelease(options: PrepareReleaseOptions): string { console.log(`Current version in package.json: ${currentVersion}`); console.log('Fetching published versions from NPM...'); - let npmVersions = getAllNpmVersions(packageName); - let latestNpmVersion = getLatestNpmVersion(packageName); - - if (!latestNpmVersion && npmVersions.size === 0) { - const registryMetadata = getNpmMetadataFromRegistry(packageName); - if (registryMetadata) { - console.log('npm view returned no versions; using public registry HTTP metadata instead'); - npmVersions = registryMetadata.versions; - latestNpmVersion = registryMetadata.latest; - } - } + const { versions: npmVersions, latest: latestNpmVersion } = resolvePublishedNpmState(packageName); if (latestNpmVersion) { console.log(`Latest version on NPM: ${latestNpmVersion}`); console.log(`Total published versions: ${npmVersions.size}`); } else { - console.log('No version found on NPM (new package or registry unavailable)'); + console.log('No version found on NPM (new package)'); } const isVersionTaken = (version: string): boolean => @@ -238,24 +346,18 @@ export function prepareRelease(options: PrepareReleaseOptions): string { console.log('✅ Updated package.json with new version'); + const lastTag = describePreviousReleaseTag(rootDir); + if (lastTag) { + console.log(`Last tag: ${lastTag}`); + } else { + console.log('No previous release tag found, using recent commit history'); + } + let commits: string; - let lastTag: string | null = null; try { - lastTag = execSync('git describe --tags --abbrev=0 2>/dev/null', { - cwd: rootDir, - encoding: 'utf8', - }).trim(); - console.log(`Last tag: ${lastTag}`); - commits = execSync(`git log --oneline --format="%s" ${lastTag}..HEAD`, { - cwd: rootDir, - encoding: 'utf8', - }).trim(); + commits = readCommitsSince(rootDir, lastTag); } catch { - console.log('No previous tag found, using recent commit history'); - commits = execSync('git log --oneline --format="%s" -n 20', { - cwd: rootDir, - encoding: 'utf8', - }).trim(); + commits = ''; } if (!commits) { diff --git a/test/unit/daml/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts index 27f4217..01c6c2e 100644 --- a/test/unit/daml/bundle-dependencies.test.ts +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -160,6 +160,64 @@ describe('daml-js-bundle config', (): void => { ).toThrow(/Invalid test.presets\[0\]/); }); + it('rejects unsafe rootIndex.outputDir and copy paths', (): void => { + expect(() => + parseDamlJsBundleConfig( + { + rootIndex: { + outputDir: '../outside', + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo'], + namespaces: ['Demo'], + }, + }, + 'test' + ) + ).toThrow(/Unsafe test\.rootIndex\.outputDir/); + + expect(() => + parseDamlJsBundleConfig( + { + rootIndex: { + outputDir: 'dist', + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo'], + namespaces: ['Demo'], + }, + }, + 'test' + ) + ).toThrow(/must resolve to a directory named "lib"/); + + expect(() => + parseDamlJsBundleConfig( + { + rootIndex: { + sourcePackage: { namePrefix: 'Demo' }, + copy: ['../escape'], + namespaces: ['Demo'], + }, + }, + 'test' + ) + ).toThrow(/Unsafe test\.rootIndex\.copy\[0\]/); + }); + + it('accepts nested outputDir when basename is lib', (): void => { + const parsed = parseDamlJsBundleConfig( + { + rootIndex: { + outputDir: 'packages/demo/lib', + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo'], + namespaces: ['Demo'], + }, + }, + 'test' + ); + expect(parsed.rootIndex?.outputDir).toBe('packages/demo/lib'); + }); + it('resolves daml-js-bundle.json with defaults', (): void => { const root = mkdtempSync(join(tmpdir(), 'bundle-config-')); try { diff --git a/test/unit/daml/codegen.test.ts b/test/unit/daml/codegen.test.ts index 3b996b8..2d6a308 100644 --- a/test/unit/daml/codegen.test.ts +++ b/test/unit/daml/codegen.test.ts @@ -24,7 +24,7 @@ import { fixSpliceRefs, updateGeneratedPackages, } from '../../../src/daml/codegen'; -import { parseChangelogRepo, selectReleaseVersion } from '../../../src/prepare-release'; +import { parseChangelogRepo, parseVersion, selectReleaseVersion } from '../../../src/prepare-release'; describe('collapseManifestLines', (): void => { it('drops map files and collapses js/d.ts pairs', (): void => { @@ -39,6 +39,14 @@ describe('collapseManifestLines', (): void => { ).toEqual(['README.md', 'lib/index']); }); + it('keeps extension when the js/d.ts counterpart is missing', (): void => { + expect(collapseManifestLines(['bin.js', 'lib/index.js', 'lib/index.d.ts'])).toEqual([ + 'bin.js', + 'lib/index', + ]); + expect(collapseManifestLines(['types.d.ts', 'README.md'])).toEqual(['README.md', 'types.d.ts']); + }); + it('throws when no files remain', (): void => { expect(() => collapseManifestLines([])).toThrow(/No files found/); }); @@ -90,6 +98,34 @@ describe('generated output helpers', (): void => { rmSync(dir, { recursive: true, force: true }); } }); + + it('rewrites both single- and double-quoted generated imports', (): void => { + const dir = mkdtempSync(join(tmpdir(), 'codegen-quotes-')); + try { + writeGeneratedOutputPair(dir, 'quoted', { + js: 'require("daml.js/foo");\nrequire(\'daml.js/foo\');\n', + dts: 'export * from "daml.js/foo";\nexport * from \'daml.js/foo\';\n', + }); + const target = join(dir, 'rel-target'); + mkdirSync(target, { recursive: true }); + applyGeneratedImportRewrites(dir, [ + { + importPaths: ['daml.js/foo'], + resolveTarget: () => target, + }, + ]); + const js = readFileSync(join(dir, 'quoted.js'), 'utf8'); + const dts = readFileSync(join(dir, 'quoted.d.ts'), 'utf8'); + expect(js).toContain('require("./rel-target")'); + expect(js).toContain("require('./rel-target')"); + expect(js).not.toContain('daml.js/foo'); + expect(dts).toContain('from "./rel-target";'); + expect(dts).toContain("from './rel-target';"); + expect(dts).not.toContain('daml.js/foo'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe('updateGeneratedPackages', (): void => { @@ -218,9 +254,25 @@ describe('selectReleaseVersion / changelog repo', (): void => { expect(selectReleaseVersion('0.0.1', '0.0.0', withTakenVersions('0.0.0'))).toBe('0.0.1'); }); + it('rejects invalid exact semver strings before Number conversion', (): void => { + expect(parseVersion('1e2.0.0')).toBeNull(); + expect(parseVersion('01.2.3')).toBeNull(); + expect(parseVersion('1..2')).toBeNull(); + expect(parseVersion('1.2')).toBeNull(); + expect(parseVersion('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 }); + expect(() => selectReleaseVersion('1e2.0.0', null, withTakenVersions())).toThrow( + /Invalid version format/ + ); + }); + it('parses changelog repo from package.json repository url', (): void => { expect( parseChangelogRepo({ type: 'git', url: 'git+https://github.com/Fairmint/canton-assets.git' }) ).toBe('Fairmint/canton-assets'); }); + + it('keeps dots in the repository name before an optional .git suffix', (): void => { + expect(parseChangelogRepo('https://github.com/acme/sdk.js.git')).toBe('acme/sdk.js'); + expect(parseChangelogRepo('git@github.com:acme/sdk.js.git')).toBe('acme/sdk.js'); + }); }); diff --git a/test/unit/scripts/prepare-release.test.ts b/test/unit/scripts/prepare-release.test.ts index 5e10648..6d4a716 100644 --- a/test/unit/scripts/prepare-release.test.ts +++ b/test/unit/scripts/prepare-release.test.ts @@ -1,4 +1,4 @@ -import { selectReleaseVersion } from '../../../scripts/prepare-release'; +import { selectReleaseVersion, parseVersion, parseChangelogRepo } from '../../../scripts/prepare-release'; describe('selectReleaseVersion', (): void => { const withTakenVersions = (...versions: string[]) => { @@ -32,4 +32,15 @@ describe('selectReleaseVersion', (): void => { // Mirrors the first post-bootstrap publish: npm has 0.1.0, manifest still says 0.1.0. expect(selectReleaseVersion('0.1.0', '0.1.0', withTakenVersions('0.1.0'))).toBe('0.1.1'); }); + + it('rejects non-exact semver before selecting a version', (): void => { + expect(parseVersion('01.2.3')).toBeNull(); + expect(() => selectReleaseVersion('1..2', null, withTakenVersions())).toThrow( + /Invalid version format/ + ); + }); + + it('parses github repos whose names contain dots', (): void => { + expect(parseChangelogRepo('https://github.com/acme/sdk.js.git')).toBe('acme/sdk.js'); + }); }); From 175c600ff9f5460fc5f19e72a6fbf1ebc9b0b9ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 12:34:33 +0000 Subject: [PATCH 06/12] fix: narrow npm-view error message typing in prepare-release Co-authored-by: hardlydiff --- src/prepare-release.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/prepare-release.ts b/src/prepare-release.ts index d677dd0..8586116 100644 --- a/src/prepare-release.ts +++ b/src/prepare-release.ts @@ -174,7 +174,9 @@ export function resolvePublishedNpmState(packageName: string): { throw new Error( `Unable to determine published versions for ${packageName} (failing closed). ` + - `registry: ${registry.message}; npm view: ${npmView.message}` + `registry: ${registry.message}; npm view: ${ + npmView.kind === 'error' ? npmView.message : 'package not found (ambiguous after registry error)' + }` ); } From f2300f6690ae3c37ae51ae3c54203c5f7cc79cc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 12:52:01 +0000 Subject: [PATCH 07/12] fix: skip npm publish on CI autofix pushes to main Align with canton-assets / canton-privy-sdk: do not run prepare-release on autofix or [skip publish] commits (or github-actions[bot] pushes). Manual workflow_dispatch still publishes. Co-authored-by: hardlydiff --- .github/workflows/publish.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ac2675..9121274 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,16 @@ concurrency: jobs: publish: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + # Skip CI autofix pushes (and explicit [skip publish]): prepare-release always + # patch-bumps from npm latest, so a lint-only main push would cut another release. + # workflow_dispatch always runs. PAT autofix sets actor to the PAT owner, so the + # commit message is the reliable signal; actor check covers GITHUB_TOKEN pushes. + if: > + github.ref == 'refs/heads/main' && + (github.event_name != 'push' || + (!contains(github.event.head_commit.message, 'ci: auto-fix') && + !contains(github.event.head_commit.message, '[skip publish]') && + github.actor != 'github-actions[bot]')) permissions: contents: write packages: write From 920d4a3076dcbb33f96d5d9a8a76605f7018b870 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 12:55:35 +0000 Subject: [PATCH 08/12] fix: gate bundle rewrites on apply success; prepare runs tsc only Bugbot: prepare now invokes tsc directly (no nested npm run build), and failed/missing preset materialization skips import rewrite and package.json dep removal so packages are not left pointing at missing __bundled__ paths. Co-authored-by: hardlydiff --- package.json | 2 +- src/daml/codegen/bundle-dependencies.ts | 10 +++++- src/daml/codegen/bundle-presets.ts | 36 +++++++++++++++---- test/unit/daml/bundle-dependencies.test.ts | 41 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index f6a482f..f02cd1b 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "localnet:cip56-transfer": "CANTON_CIP56_REQUIRE_LOCALNET=1 tsx scripts/run-cip56-transfer-smoke.ts", "pack:check": "npm run check:package-artifacts", "prepack": "npm run clean && npm run build", - "prepare": "npm run build", + "prepare": "tsc -p tsconfig.json", "prepare-release": "tsx scripts/prepare-release.ts", "prepublishOnly": "npm run prepack", "test": "npm run -s typecheck && jest", diff --git a/src/daml/codegen/bundle-dependencies.ts b/src/daml/codegen/bundle-dependencies.ts index 15f1c65..8405bca 100644 --- a/src/daml/codegen/bundle-dependencies.ts +++ b/src/daml/codegen/bundle-dependencies.ts @@ -270,12 +270,20 @@ export function bundleDependenciesForTarget(options: { continue; } - preset.apply({ + const materialized = preset.apply({ targetDir, generatedJsDir: options.generatedJsDir, pins: options.pins, willBundleAmulet, }); + // Skip rewrite + dep removal when apply could not materialize sources — + // otherwise imports would point at missing __bundled__ paths. + if (!materialized) { + console.log( + `⚠️ Skipping rewrite/cleanup for preset ${preset.id}: generated dependency tree missing` + ); + continue; + } applied.push(preset.id); rewriteRules.push(...preset.rewriteRules(targetDir, options.pins)); packageJsonDeps.push(...preset.importSpecs(options.pins)); diff --git a/src/daml/codegen/bundle-presets.ts b/src/daml/codegen/bundle-presets.ts index 4ef7fb5..b8d63ed 100644 --- a/src/daml/codegen/bundle-presets.ts +++ b/src/daml/codegen/bundle-presets.ts @@ -122,7 +122,13 @@ export interface BundlePresetDefinition { * Special cases (featured-app-v2, always-on da-internal-template) override. */ shouldApply?: (ctx: BundleApplyContext, detected: boolean) => boolean; - apply: (ctx: BundleApplyContext) => void; + /** + * Materialize bundled sources into the target package. + * Return `true` only when the preset tree is ready for import rewrite / + * package.json cleanup. Return `false` when required generated sources are + * missing so callers leave original imports and dependencies intact. + */ + apply: (ctx: BundleApplyContext) => boolean; rewriteRules: (targetDir: string, pins: BundlePins) => GeneratedImportRewriteRule[]; } @@ -269,6 +275,7 @@ export declare const DA: { Internal: { Template: typeof Template } }; ` ); console.log('✅ Created bundled DA.Internal.Template structure'); + return true; }, rewriteRules: (targetDir) => [ { @@ -342,6 +349,7 @@ export declare const Splice: { Api: { FeaturedAppRightV1: typeof FeaturedAppRigh ` ); console.log('✅ Created bundled splice-api-featured-app-v1 structure'); + return true; }, rewriteRules: (targetDir) => [ { @@ -368,7 +376,7 @@ export declare const Splice: { Api: { FeaturedAppRightV1: typeof FeaturedAppRigh ); if (!fs.existsSync(sourceDir)) { console.log('⚠️ splice-api-featured-app-v2 FeaturedAppRightV2 directory not found'); - return; + return false; } createDirectoryIfNotExists(path.join(ctx.targetDir, 'lib/Splice/Api')); copyDirectory(sourceDir, path.join(ctx.targetDir, 'lib/Splice/Api/FeaturedAppRightV2')); @@ -385,6 +393,7 @@ export declare const Splice: { Api: { FeaturedAppRightV2: typeof FeaturedAppRigh ` ); console.log('✅ Copied splice-api-featured-app-v2 FeaturedAppRightV2 modules'); + return true; }, rewriteRules: (targetDir) => [ { @@ -412,9 +421,10 @@ export declare const Splice: { Api: { FeaturedAppRightV2: typeof FeaturedAppRigh 'splice-amulet Splice directory' ) ) { - return; + return false; } console.log('✅ Copied splice-amulet Splice modules'); + return true; }, rewriteRules: (targetDir, pins) => [ { @@ -439,7 +449,7 @@ export declare const Splice: { Api: { FeaturedAppRightV2: typeof FeaturedAppRigh 'lib/DA/Time' ); if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Time'), 'DA Time Types')) { - return; + return false; } ensureWrapper( ctx.targetDir, @@ -454,6 +464,7 @@ export declare const DA: { Time: { Types: typeof Types } }; ` ); console.log('✅ Copied DA Time Types modules'); + return true; }, rewriteRules: (targetDir) => [ { @@ -478,7 +489,7 @@ export declare const DA: { Time: { Types: typeof Types } }; 'lib/DA/Types' ); if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Types'), 'DA Types')) { - return; + return false; } ensureWrapper( ctx.targetDir, @@ -493,6 +504,7 @@ export declare const DA: { Types: typeof Types }; ` ); console.log('✅ Copied DA Types modules'); + return true; }, rewriteRules: (targetDir) => [ { @@ -517,7 +529,7 @@ export declare const DA: { Types: typeof Types }; 'lib/DA/Set' ); if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Set'), 'DA Set Types')) { - return; + return false; } ensureWrapper( ctx.targetDir, @@ -532,6 +544,7 @@ export declare const DA: { Set: { Types: typeof Types } }; ` ); console.log('✅ Copied DA Set Types modules'); + return true; }, rewriteRules: (targetDir) => [ { @@ -559,15 +572,22 @@ export declare const DA: { Set: { Types: typeof Types } }; ], apply: (ctx) => { console.log('📦 Bundling Splice API Token dependencies...'); + let copiedCount = 0; for (const pkg of TOKEN_V1_PACKAGES) { const sourceDir = path.join(packageDir(ctx.generatedJsDir, pkg.dirName), pkg.relModule); const destDir = path.join(ctx.targetDir, pkg.relModule); if (fs.existsSync(sourceDir)) { copyDirectory(sourceDir, destDir); console.log(`✅ Copied ${pkg.wrapperName}`); + copiedCount += 1; } } + if (copiedCount === 0) { + console.log('⚠️ No splice-api-token-v1 source packages found'); + return false; + } + for (const pkg of TOKEN_V1_PACKAGES) { const relPath = `../../Splice/Api/Token/${pkg.wrapperKey}`; ensureWrapper( @@ -585,6 +605,7 @@ export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } ` ); } + return true; }, rewriteRules: (targetDir) => TOKEN_V1_PACKAGES.map((pkg) => ({ @@ -609,7 +630,7 @@ export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } } else { const alt = path.join(depRoot, 'lib/Splice'); if (!copyModuleTreeOrWarn(alt, path.join(ctx.targetDir, 'lib/Splice'), 'splice-token-standard-utils')) { - return; + return false; } } ensureWrapper( @@ -625,6 +646,7 @@ export declare const Splice: { TokenStandard: typeof TokenStandard }; ` ); console.log('✅ Copied splice-token-standard-utils modules'); + return true; }, rewriteRules: (targetDir, pins) => [ { diff --git a/test/unit/daml/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts index 01c6c2e..183da29 100644 --- a/test/unit/daml/bundle-dependencies.test.ts +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -316,6 +316,47 @@ require('daml.js/splice-api-featured-app-v2-1.0.0'); rmSync(root, { recursive: true, force: true }); } }); + + it('does not rewrite or strip deps when preset source is missing', (): void => { + const root = scaffoldRepo(); + try { + // Intentionally omit daml-stdlib-DA-Time-Types generated tree. + scaffoldDependencyTemplates(root); + rmSync(join(root, 'generated', 'js', 'daml-stdlib-DA-Time-Types-1.0.0'), { + recursive: true, + force: true, + }); + const pkgDir = scaffoldGeneratedPackage(root); + + const applied = bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['da-internal-template', 'da-time-types'], + forcePresets: ['da-time-types'], + }); + + expect(applied).toEqual(['da-internal-template']); + expect(applied).not.toContain('da-time-types'); + expect(existsSync(join(pkgDir, 'lib/__bundled__/daml-stdlib-DA-Time-Types'))).toBe(false); + + const demoJs = readFileSync(join(pkgDir, 'lib/Demo/module.js'), 'utf8'); + expect(demoJs).toContain("require('daml.js/daml-stdlib-DA-Time-Types-1.0.0')"); + expect(demoJs).not.toContain('__bundled__/daml-stdlib-DA-Time-Types'); + // Successful preset still rewrites. + expect(demoJs).toContain('__bundled__/ghc-stdlib-DA-Internal-Template'); + + const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + expect(pkgJson.dependencies?.['daml.js/daml-stdlib-DA-Time-Types-1.0.0']).toBe( + 'file:../daml-stdlib-DA-Time-Types-1.0.0' + ); + expect(pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0']).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('createRootIndex', (): void => { From ec7ce5d29a9ef8be9481629d30b85b1c74a9e6ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 13:11:27 +0000 Subject: [PATCH 09/12] fix: gate root-index rewrites; partial token bundle; semver latest - createRootIndex only rewrites presets that materialized (apply success + detection targets present), matching bundleDependenciesForTarget - splice-token-v1 wrappers/rewrites/dep cleanup only for packages that actually copied; zero copies still returns false - npm-view latest fallback uses semver compare instead of string sort Co-authored-by: hardlydiff --- scripts/prepare-release.ts | 2 + src/daml/codegen/bundle-dependencies.ts | 7 +- src/daml/codegen/bundle-presets.ts | 21 ++- src/daml/codegen/create-root-index.ts | 28 ++-- src/prepare-release.ts | 23 +++- test/unit/daml/bundle-dependencies.test.ts | 142 +++++++++++++++++++++ 6 files changed, 204 insertions(+), 19 deletions(-) diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 09a69bd..0e92111 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -17,8 +17,10 @@ if (require.main === module) { } export { + compareVersions, parseChangelogRepo, parseVersion, + pickLatestSemver, prepareRelease, selectReleaseVersion, } from '../src/prepare-release'; diff --git a/src/daml/codegen/bundle-dependencies.ts b/src/daml/codegen/bundle-dependencies.ts index 8405bca..b710f2f 100644 --- a/src/daml/codegen/bundle-dependencies.ts +++ b/src/daml/codegen/bundle-dependencies.ts @@ -285,8 +285,11 @@ export function bundleDependenciesForTarget(options: { continue; } applied.push(preset.id); - rewriteRules.push(...preset.rewriteRules(targetDir, options.pins)); - packageJsonDeps.push(...preset.importSpecs(options.pins)); + // Derive cleanup keys from rewrite rules so partial presets (e.g. splice-token-v1) + // only strip deps for packages that actually materialized. + const rules = preset.rewriteRules(targetDir, options.pins); + rewriteRules.push(...rules); + packageJsonDeps.push(...rules.flatMap((rule) => rule.importPaths)); } ensureBundledDANamespaceIndexes(targetDir); diff --git a/src/daml/codegen/bundle-presets.ts b/src/daml/codegen/bundle-presets.ts index b8d63ed..fc0a009 100644 --- a/src/daml/codegen/bundle-presets.ts +++ b/src/daml/codegen/bundle-presets.ts @@ -572,23 +572,32 @@ export declare const DA: { Set: { Types: typeof Types } }; ], apply: (ctx) => { console.log('📦 Bundling Splice API Token dependencies...'); - let copiedCount = 0; + const copied: typeof TOKEN_V1_PACKAGES = []; for (const pkg of TOKEN_V1_PACKAGES) { const sourceDir = path.join(packageDir(ctx.generatedJsDir, pkg.dirName), pkg.relModule); const destDir = path.join(ctx.targetDir, pkg.relModule); if (fs.existsSync(sourceDir)) { copyDirectory(sourceDir, destDir); console.log(`✅ Copied ${pkg.wrapperName}`); - copiedCount += 1; + copied.push(pkg); + } else { + console.log(`⚠️ ${pkg.wrapperName} not found at ${sourceDir}`); } } - if (copiedCount === 0) { + if (copied.length === 0) { console.log('⚠️ No splice-api-token-v1 source packages found'); return false; } - for (const pkg of TOKEN_V1_PACKAGES) { + if (copied.length < TOKEN_V1_PACKAGES.length) { + console.log( + `⚠️ Partial splice-token-v1 bundle: ${copied.length}/${TOKEN_V1_PACKAGES.length} packages copied` + ); + } + + // Only emit wrappers for packages that actually landed — rewriteRules filters the same way. + for (const pkg of copied) { const relPath = `../../Splice/Api/Token/${pkg.wrapperKey}`; ensureWrapper( ctx.targetDir, @@ -608,7 +617,9 @@ export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } return true; }, rewriteRules: (targetDir) => - TOKEN_V1_PACKAGES.map((pkg) => ({ + TOKEN_V1_PACKAGES.filter((pkg) => + fs.existsSync(path.join(targetDir, pkg.relModule)) + ).map((pkg) => ({ importPaths: importVariants(pkg.dirName), resolveTarget: () => path.join(targetDir, 'lib/__bundled__', pkg.wrapperName), })), diff --git a/src/daml/codegen/create-root-index.ts b/src/daml/codegen/create-root-index.ts index d1a3d2f..4caf2a2 100644 --- a/src/daml/codegen/create-root-index.ts +++ b/src/daml/codegen/create-root-index.ts @@ -255,22 +255,30 @@ export function createRootIndex(options: CreateRootIndexOptions): { writeRootIndexFiles(destLib, rootIndex.namespaces, rootIndex.templateConstants); const postPresets = rootIndex.postBundlePresets ?? []; - if (postPresets.length > 0) { - // Preset apply paths assume targetDir/lib/…; packageRoot is dirname(destLib). - applyBundlePresets({ - targetDir: packageRoot, - generatedJsDir: config.absoluteGeneratedJsDir, - pins: config.pins, - presets: postPresets, - }); - } + // Only presets that actually materialized are safe to rewrite onto __bundled__. + const appliedPostPresets = + postPresets.length > 0 + ? applyBundlePresets({ + targetDir: packageRoot, + generatedJsDir: config.absoluteGeneratedJsDir, + pins: config.pins, + presets: postPresets, + }) + : []; const shouldPatch = rootIndex.patchBundledImports ?? true; if (shouldPatch) { + // config.presets are expected from an earlier bundle-dependencies + copy step; + // only rewrite when their bundled artifacts are present. postBundlePresets only + // rewrite when applyBundlePresets reported success (same gate as bundleDependenciesForTarget). + const candidatePresets = [...new Set([...config.presets, ...appliedPostPresets])]; + const presetsToPatch = candidatePresets.filter((id) => + BUNDLE_PRESETS[id].detectionTargets(packageRoot).some((target) => fs.existsSync(target)) + ); patchBundledDependencyImports(destLib, { generatedJsDir: config.absoluteGeneratedJsDir, pins: config.pins, - presets: [...new Set([...config.presets, ...postPresets])], + presets: presetsToPatch, }); } diff --git a/src/prepare-release.ts b/src/prepare-release.ts index 8586116..06d9df4 100644 --- a/src/prepare-release.ts +++ b/src/prepare-release.ts @@ -131,7 +131,8 @@ function getNpmMetadataFromNpmView(packageName: string): NpmLookupResult { }).trim(); latest = latestRaw || null; } catch { - latest = versions.size > 0 ? [...versions].sort().at(-1) ?? null : null; + // Semver compare — lexicographic sort ranks "0.9.0" above "0.10.0". + latest = pickLatestSemver(versions); } return { kind: 'found', latest, versions }; @@ -194,12 +195,30 @@ export function parseVersion(version: string): ParsedVersion | null { } /** Compare two parsed semantic versions. */ -function compareVersions(left: ParsedVersion, right: ParsedVersion): number { +export function compareVersions(left: ParsedVersion, right: ParsedVersion): number { if (left.major !== right.major) return left.major - right.major; if (left.minor !== right.minor) return left.minor - right.minor; return left.patch - right.patch; } +/** + * Pick the highest exact `x.y.z` from a versions list (semver, not lexicographic). + * Non-exact versions are ignored. Returns null when none parse. + */ +export function pickLatestSemver(versions: Iterable): string | null { + let best: ParsedVersion | null = null; + let bestRaw: string | null = null; + for (const version of versions) { + const parsed = parseVersion(version); + if (!parsed) continue; + if (!best || compareVersions(parsed, best) > 0) { + best = parsed; + bestRaw = version; + } + } + return bestRaw; +} + /** Find the next available version by incrementing patch until free on tags and npm */ function findNextAvailableVersion( isVersionTaken: (version: string) => boolean, diff --git a/test/unit/daml/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts index 183da29..3c1d3dc 100644 --- a/test/unit/daml/bundle-dependencies.test.ts +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -357,6 +357,99 @@ require('daml.js/splice-api-featured-app-v2-1.0.0'); rmSync(root, { recursive: true, force: true }); } }); + + it('partial splice-token-v1 only rewrites packages that copied', (): void => { + const root = scaffoldRepo(); + try { + scaffoldDependencyTemplates(root); + const pkgDir = scaffoldGeneratedPackage(root); + + // Only materialize HoldingV1 — leave the other five token packages missing. + writePair( + join( + root, + 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1' + ), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Holding = {}; +`, + `export declare const Holding: object;\n` + ); + writePair( + join( + root, + 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1' + ), + 'index', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function __export(m) { for (var p in m) exports[p] = m[p]; } +__export(require('./module')); +`, + `export * from './module';\n` + ); + + writePair( + join(pkgDir, 'lib', 'Demo'), + 'module', + `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +var holding = require('daml.js/splice-api-token-holding-v1-1.0.0'); +var burnMint = require('daml.js/splice-api-token-burn-mint-v1-1.0.0'); +exports.Demo = { holding: holding, burnMint: burnMint }; +` + ); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify( + { + name: '@fairmint/demo-daml-js', + version: '0.0.1', + dependencies: { + 'daml.js/splice-api-token-holding-v1-1.0.0': + 'file:../splice-api-token-holding-v1-1.0.0', + 'daml.js/splice-api-token-burn-mint-v1-1.0.0': + 'file:../splice-api-token-burn-mint-v1-1.0.0', + }, + }, + null, + 4 + ) + ); + + const applied = bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['splice-token-v1'], + forcePresets: ['splice-token-v1'], + }); + + expect(applied).toEqual(['splice-token-v1']); + expect(existsSync(join(pkgDir, 'lib/Splice/Api/Token/HoldingV1/module.js'))).toBe(true); + expect(existsSync(join(pkgDir, 'lib/__bundled__/splice-api-token-holding-v1'))).toBe(true); + // Missing packages must not get stub wrappers. + expect(existsSync(join(pkgDir, 'lib/__bundled__/splice-api-token-burn-mint-v1'))).toBe(false); + expect(existsSync(join(pkgDir, 'lib/Splice/Api/Token/BurnMintV1'))).toBe(false); + + const demoJs = readFileSync(join(pkgDir, 'lib/Demo/module.js'), 'utf8'); + expect(demoJs).toContain('__bundled__/splice-api-token-holding-v1'); + expect(demoJs).toContain("require('daml.js/splice-api-token-burn-mint-v1-1.0.0')"); + expect(demoJs).not.toContain('__bundled__/splice-api-token-burn-mint-v1'); + + const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as { + dependencies?: Record; + }; + expect(pkgJson.dependencies?.['daml.js/splice-api-token-holding-v1-1.0.0']).toBeUndefined(); + expect(pkgJson.dependencies?.['daml.js/splice-api-token-burn-mint-v1-1.0.0']).toBe( + 'file:../splice-api-token-burn-mint-v1-1.0.0' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); describe('createRootIndex', (): void => { @@ -422,4 +515,53 @@ exports.Demo = { templateId: 'Demo:Demo:Demo' }; rmSync(root, { recursive: true, force: true }); } }); + + it('does not rewrite failed postBundlePresets onto missing __bundled__ paths', (): void => { + const root = scaffoldRepo(); + try { + scaffoldDependencyTemplates(root); + // Remove DA Time Types so postBundlePresets apply fails. + rmSync(join(root, 'generated', 'js', 'daml-stdlib-DA-Time-Types-1.0.0'), { + recursive: true, + force: true, + }); + const pkgDir = scaffoldGeneratedPackage(root); + // Bundle only the template preset into the source package. + bundleDependenciesForTarget({ + targetDir: pkgDir, + generatedJsDir: join(root, 'generated', 'js'), + pins: { amulet: '0.1.19', tokenStandardUtils: '2.0.0' }, + presets: ['da-internal-template'], + }); + + writeFileSync( + join(root, 'daml-js-bundle.json'), + JSON.stringify( + { + presets: ['da-internal-template'], + rootIndex: { + sourcePackage: { namePrefix: 'Demo' }, + copy: ['Demo', 'DA', '__bundled__'], + namespaces: ['Demo', 'DA'], + postBundlePresets: ['da-time-types'], + }, + }, + null, + 2 + ) + ); + + createRootIndex({ rootDir: root }); + + expect(existsSync(join(root, 'lib/__bundled__/daml-stdlib-DA-Time-Types'))).toBe(false); + const demoJs = readFileSync(join(root, 'lib/Demo/module.js'), 'utf8'); + // Failed postBundlePreset must leave the original import intact. + expect(demoJs).toContain("require('daml.js/daml-stdlib-DA-Time-Types-1.0.0')"); + expect(demoJs).not.toContain('__bundled__/daml-stdlib-DA-Time-Types'); + // Previously bundled + copied preset may still be rewritten. + expect(demoJs).toContain('__bundled__/ghc-stdlib-DA-Internal-Template'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); From 38972259df4ca734816ed11dba80c117c6d83f23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 13:11:37 +0000 Subject: [PATCH 10/12] test: cover pickLatestSemver semver ordering Co-authored-by: hardlydiff --- test/unit/scripts/prepare-release.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/unit/scripts/prepare-release.test.ts b/test/unit/scripts/prepare-release.test.ts index 6d4a716..d0e6700 100644 --- a/test/unit/scripts/prepare-release.test.ts +++ b/test/unit/scripts/prepare-release.test.ts @@ -1,4 +1,9 @@ -import { selectReleaseVersion, parseVersion, parseChangelogRepo } from '../../../scripts/prepare-release'; +import { + selectReleaseVersion, + parseVersion, + parseChangelogRepo, + pickLatestSemver, +} from '../../../scripts/prepare-release'; describe('selectReleaseVersion', (): void => { const withTakenVersions = (...versions: string[]) => { @@ -44,3 +49,15 @@ describe('selectReleaseVersion', (): void => { expect(parseChangelogRepo('https://github.com/acme/sdk.js.git')).toBe('acme/sdk.js'); }); }); + +describe('pickLatestSemver', (): void => { + it('uses semver compare instead of lexicographic string sort', (): void => { + // Lexicographic sort would pick "0.9.0" over "0.10.0". + expect(pickLatestSemver(['0.9.0', '0.10.0', '0.2.0'])).toBe('0.10.0'); + }); + + it('ignores non-exact versions and returns null when none parse', (): void => { + expect(pickLatestSemver(['1.0.0-beta', '01.2.3', 'latest'])).toBeNull(); + expect(pickLatestSemver(['1.0.0-beta', '2.0.0'])).toBe('2.0.0'); + }); +}); From 489e3c1932050bb5a48166deb755ed93feac9fff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 13:35:13 +0000 Subject: [PATCH 11/12] ci: fail on dirty tree instead of autofix publish skips Remove publish.yml autofix/[skip publish] guards. Run npm run fix in CI and fail when the working tree is dirty so contributors commit fixes locally. Co-authored-by: hardlydiff --- .github/workflows/ci.yml | 14 ++++++++++++-- .github/workflows/publish.yml | 11 +---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7510c81..44c9f68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,18 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Lint - run: npm run lint + - name: Lint and format + run: npm run fix + + - name: Fail if lint/format produced changes + run: | + if ! git diff --exit-code --quiet || ! git diff --cached --exit-code --quiet; then + echo "::error::Lint/format produced uncommitted changes. Run \`npm run fix\` locally and commit." + echo "=== DIFF ===" + git diff + echo "=== END DIFF ===" + exit 1 + fi - name: Typecheck run: npm run typecheck diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9121274..6ac2675 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,16 +28,7 @@ concurrency: jobs: publish: runs-on: ubuntu-latest - # Skip CI autofix pushes (and explicit [skip publish]): prepare-release always - # patch-bumps from npm latest, so a lint-only main push would cut another release. - # workflow_dispatch always runs. PAT autofix sets actor to the PAT owner, so the - # commit message is the reliable signal; actor check covers GITHUB_TOKEN pushes. - if: > - github.ref == 'refs/heads/main' && - (github.event_name != 'push' || - (!contains(github.event.head_commit.message, 'ci: auto-fix') && - !contains(github.event.head_commit.message, '[skip publish]') && - github.actor != 'github-actions[bot]')) + if: github.ref == 'refs/heads/main' permissions: contents: write packages: write From 925dc4db8ca594d2e0db9206f18b699ec70561aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 13:36:50 +0000 Subject: [PATCH 12/12] style: apply prettier fixes for CI dirty-tree check Run npm run fix locally so CI no longer fails on uncommitted formatting after the fail-on-dirty-tree workflow change. Co-authored-by: hardlydiff --- README.md | 28 ++++++------ src/daml/check-dar-version-policy.ts | 6 ++- src/daml/codegen/bundle-dependencies.ts | 13 +++--- src/daml/codegen/bundle-presets.ts | 50 ++++++++++------------ src/daml/codegen/create-root-index.ts | 21 +++------ src/daml/codegen/daml-js-bundle-config.ts | 25 ++++++----- src/daml/codegen/verify-package-imports.ts | 4 +- src/prepare-release.ts | 4 +- test/unit/daml/bundle-dependencies.test.ts | 47 ++++++++------------ test/unit/daml/codegen.test.ts | 17 +++----- test/unit/daml/upgrade-and-policy.test.ts | 6 +-- test/unit/scripts/canton-dev-tools.test.ts | 10 +++-- 12 files changed, 111 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 36b2ed6..8ec7c17 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,17 @@ npx canton-dev-tools fix-splice-refs --target lib Built-in presets (stdlib / Splice only): -| Preset id | Bundles | -|---|---| -| `da-internal-template` | `ghc-stdlib-DA-Internal-Template` (always applied) | -| `featured-app-v1` | `splice-api-featured-app-v1` | -| `featured-app-v2` | `splice-api-featured-app-v2` (only when amulet needs it) | -| `amulet` | `splice-amulet-` | -| `da-time-types` | `daml-stdlib-DA-Time-Types` | -| `da-types` | `daml-prim-DA-Types` | -| `da-set-types` | `daml-stdlib-DA-Set-Types` | -| `splice-token-v1` | token burn/mint, metadata, holding, allocation*, transfer-instruction | -| `splice-token-standard-utils` | `splice-token-standard-utils-` | +| Preset id | Bundles | +| ----------------------------- | ---------------------------------------------------------------------- | +| `da-internal-template` | `ghc-stdlib-DA-Internal-Template` (always applied) | +| `featured-app-v1` | `splice-api-featured-app-v1` | +| `featured-app-v2` | `splice-api-featured-app-v2` (only when amulet needs it) | +| `amulet` | `splice-amulet-` | +| `da-time-types` | `daml-stdlib-DA-Time-Types` | +| `da-types` | `daml-prim-DA-Types` | +| `da-set-types` | `daml-stdlib-DA-Set-Types` | +| `splice-token-v1` | token burn/mint, metadata, holding, allocation\*, transfer-instruction | +| `splice-token-standard-utils` | `splice-token-standard-utils-` | Pins (optional): `pins.amulet` (default `0.1.19`), `pins.tokenStandardUtils` (default `2.0.0`). @@ -262,7 +262,11 @@ Optional overrides, in order: ### Library import ```ts -import { prepareBuild, discoverManagedPackages, runCodegenJs } from '@fairmint/canton-dev-tools/daml'; +import { + prepareBuild, + discoverManagedPackages, + runCodegenJs, +} from '@fairmint/canton-dev-tools/daml'; ``` ## TypeScript helpers diff --git a/src/daml/check-dar-version-policy.ts b/src/daml/check-dar-version-policy.ts index 3e0f7f6..15bad46 100644 --- a/src/daml/check-dar-version-policy.ts +++ b/src/daml/check-dar-version-policy.ts @@ -107,7 +107,11 @@ function loadWatchPathsFromPackageJson(rootDir: string): string[] | undefined { } const cantonDevTools = Reflect.get(parsed, 'cantonDevTools'); if (cantonDevTools === undefined) return undefined; - if (typeof cantonDevTools !== 'object' || cantonDevTools === null || Array.isArray(cantonDevTools)) { + if ( + typeof cantonDevTools !== 'object' || + cantonDevTools === null || + Array.isArray(cantonDevTools) + ) { throw new Error(`Invalid package.json cantonDevTools (expected object): ${packageJsonPath}`); } return readStringArrayField( diff --git a/src/daml/codegen/bundle-dependencies.ts b/src/daml/codegen/bundle-dependencies.ts index b710f2f..5f62c32 100644 --- a/src/daml/codegen/bundle-dependencies.ts +++ b/src/daml/codegen/bundle-dependencies.ts @@ -173,10 +173,7 @@ function updateMainIndex(targetDir: string): void { } } -function removeLocalDependencies( - targetDir: string, - deps: string[] -): void { +function removeLocalDependencies(targetDir: string, deps: string[]): void { console.log('🗑️ Removing local dependencies from package.json...'); const packageJsonPath = path.join(targetDir, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -310,9 +307,11 @@ export function bundleDependenciesForTarget(options: { return applied; } -export function bundleDependencies( - options: BundleDependenciesOptions -): { config: ResolvedDamlJsBundleConfig; processed: string[]; applied: Record } { +export function bundleDependencies(options: BundleDependenciesOptions): { + config: ResolvedDamlJsBundleConfig; + processed: string[]; + applied: Record; +} { const config = resolveDamlJsBundleConfig({ rootDir: options.rootDir, configPath: options.configPath, diff --git a/src/daml/codegen/bundle-presets.ts b/src/daml/codegen/bundle-presets.ts index fc0a009..d25a037 100644 --- a/src/daml/codegen/bundle-presets.ts +++ b/src/daml/codegen/bundle-presets.ts @@ -9,11 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { GeneratedImportRewriteRule } from './generated-output-helpers'; import { writeGeneratedOutputPair } from './generated-output-helpers'; -import { - copyDirectory, - createDirectoryIfNotExists, - createNamespaceIndexDts, -} from './bundle-fs'; +import { copyDirectory, createDirectoryIfNotExists, createNamespaceIndexDts } from './bundle-fs'; export const BUNDLE_PRESET_IDS = [ 'da-internal-template', @@ -54,12 +50,7 @@ function packageDir(generatedJsDir: string, nameWithVersion: string): string { return path.join(generatedJsDir, nameWithVersion); } -function ensureWrapper( - targetDir: string, - wrapperName: string, - js: string, - dts: string -): void { +function ensureWrapper(targetDir: string, wrapperName: string, js: string, dts: string): void { const wrapperDir = path.join(targetDir, 'lib', '__bundled__', wrapperName); writeGeneratedOutputPair(wrapperDir, 'index', { js, dts }); } @@ -96,11 +87,7 @@ exports.${childName} = ${childName}; }); } -function copyModuleTreeOrWarn( - sourceDir: string, - destDir: string, - label: string -): boolean { +function copyModuleTreeOrWarn(sourceDir: string, destDir: string, label: string): boolean { if (!fs.existsSync(sourceDir)) { console.log(`⚠️ ${label} not found at ${sourceDir}`); return false; @@ -448,7 +435,9 @@ export declare const Splice: { Api: { FeaturedAppRightV2: typeof FeaturedAppRigh packageDir(ctx.generatedJsDir, 'daml-stdlib-DA-Time-Types-1.0.0'), 'lib/DA/Time' ); - if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Time'), 'DA Time Types')) { + if ( + !copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Time'), 'DA Time Types') + ) { return false; } ensureWrapper( @@ -528,7 +517,9 @@ export declare const DA: { Types: typeof Types }; packageDir(ctx.generatedJsDir, 'daml-stdlib-DA-Set-Types-1.0.0'), 'lib/DA/Set' ); - if (!copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Set'), 'DA Set Types')) { + if ( + !copyModuleTreeOrWarn(sourceDir, path.join(ctx.targetDir, 'lib/DA/Set'), 'DA Set Types') + ) { return false; } ensureWrapper( @@ -557,8 +548,7 @@ export declare const DA: { Set: { Types: typeof Types } }; 'splice-token-v1': { id: 'splice-token-v1', - importSpecs: () => - TOKEN_V1_PACKAGES.flatMap((pkg) => importVariants(pkg.dirName)), + importSpecs: () => TOKEN_V1_PACKAGES.flatMap((pkg) => importVariants(pkg.dirName)), detectionTargets: (targetDir) => [ path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'BurnMintV1'), path.join(targetDir, 'lib', 'Splice', 'Api', 'Token', 'MetadataV1'), @@ -617,12 +607,12 @@ export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } return true; }, rewriteRules: (targetDir) => - TOKEN_V1_PACKAGES.filter((pkg) => - fs.existsSync(path.join(targetDir, pkg.relModule)) - ).map((pkg) => ({ - importPaths: importVariants(pkg.dirName), - resolveTarget: () => path.join(targetDir, 'lib/__bundled__', pkg.wrapperName), - })), + TOKEN_V1_PACKAGES.filter((pkg) => fs.existsSync(path.join(targetDir, pkg.relModule))).map( + (pkg) => ({ + importPaths: importVariants(pkg.dirName), + resolveTarget: () => path.join(targetDir, 'lib/__bundled__', pkg.wrapperName), + }) + ), }, 'splice-token-standard-utils': { @@ -640,7 +630,13 @@ export declare const Splice: { Api: { Token: { ${pkg.wrapperKey}: typeof mod } } copyDirectory(sourceDir, path.join(ctx.targetDir, 'lib/Splice/TokenStandard')); } else { const alt = path.join(depRoot, 'lib/Splice'); - if (!copyModuleTreeOrWarn(alt, path.join(ctx.targetDir, 'lib/Splice'), 'splice-token-standard-utils')) { + if ( + !copyModuleTreeOrWarn( + alt, + path.join(ctx.targetDir, 'lib/Splice'), + 'splice-token-standard-utils' + ) + ) { return false; } } diff --git a/src/daml/codegen/create-root-index.ts b/src/daml/codegen/create-root-index.ts index 4caf2a2..5eaf02d 100644 --- a/src/daml/codegen/create-root-index.ts +++ b/src/daml/codegen/create-root-index.ts @@ -89,9 +89,7 @@ function renderTemplateConstantsJs( const field = entry.field ?? 'templateId'; fields.push(` ${entryName}: ${varName}.${entry.binding}.${field},`); } - exports.push( - `exports.${constName} = Object.freeze({\n${fields.join('\n')}\n});` - ); + exports.push(`exports.${constName} = Object.freeze({\n${fields.join('\n')}\n});`); } return { requires, exports }; @@ -113,13 +111,9 @@ function renderTemplateConstantsDts( imports.push(`import * as ${varName} from '${entry.from}';`); } const field = entry.field ?? 'templateId'; - fields.push( - ` readonly ${entryName}: typeof ${varName}.${entry.binding}.${field};` - ); + fields.push(` readonly ${entryName}: typeof ${varName}.${entry.binding}.${field};`); } - declarations.push( - `export declare const ${constName}: {\n${fields.join('\n')}\n};` - ); + declarations.push(`export declare const ${constName}: {\n${fields.join('\n')}\n};`); } return { imports, declarations }; @@ -135,10 +129,7 @@ function writeRootIndexFiles( const dtsConstants = renderTemplateConstantsDts(constants); const namespaceRequires = namespaces - .map( - (ns) => - `var ${ns} = require('./${ns}');\nexports.${ns} = ${ns};` - ) + .map((ns) => `var ${ns} = require('./${ns}');\nexports.${ns} = ${ns};`) .join('\n'); const indexJs = `"use strict"; @@ -153,9 +144,7 @@ ${jsConstants.requires.join('\n')} ${jsConstants.exports.join('\n')} `; - const namespaceImports = namespaces - .map((ns) => `import * as ${ns} from './${ns}';`) - .join('\n'); + const namespaceImports = namespaces.map((ns) => `import * as ${ns} from './${ns}';`).join('\n'); const namespaceExport = `export { ${namespaces.join(', ')} };`; const indexDts = `${namespaceImports} diff --git a/src/daml/codegen/daml-js-bundle-config.ts b/src/daml/codegen/daml-js-bundle-config.ts index 8aa848a..0d7c122 100644 --- a/src/daml/codegen/daml-js-bundle-config.ts +++ b/src/daml/codegen/daml-js-bundle-config.ts @@ -140,10 +140,7 @@ function parsePins(raw: unknown, label: string): DamlJsBundlePins { return pins; } -function parseTemplateConstants( - raw: unknown, - label: string -): RootIndexConfig['templateConstants'] { +function parseTemplateConstants(raw: unknown, label: string): RootIndexConfig['templateConstants'] { if (raw === undefined) return undefined; if (!isRecord(raw)) { throw new Error(`Invalid ${label} (expected object)`); @@ -199,13 +196,15 @@ function parseRootIndex(raw: unknown, label: string): RootIndexConfig { sourcePackage[key] = value; } if (!sourcePackage.name && !sourcePackage.namePrefix && !sourcePackage.key) { - throw new Error( - `${label}.sourcePackage requires at least one of name, namePrefix, or key` - ); + throw new Error(`${label}.sourcePackage requires at least one of name, namePrefix, or key`); } const copyRaw = raw['copy']; - if (!Array.isArray(copyRaw) || copyRaw.length === 0 || !copyRaw.every((v) => typeof v === 'string')) { + if ( + !Array.isArray(copyRaw) || + copyRaw.length === 0 || + !copyRaw.every((v) => typeof v === 'string') + ) { throw new Error(`Invalid ${label}.copy (expected non-empty string[])`); } const copy = (copyRaw as string[]).map((entry, index) => { @@ -260,7 +259,10 @@ function parseRootIndex(raw: unknown, label: string): RootIndexConfig { sourcePackage, copy, namespaces: namespacesRaw as string[], - templateConstants: parseTemplateConstants(raw['templateConstants'], `${label}.templateConstants`), + templateConstants: parseTemplateConstants( + raw['templateConstants'], + `${label}.templateConstants` + ), ...(postBundlePresets ? { postBundlePresets } : {}), ...(typeof patchBundledImports === 'boolean' ? { patchBundledImports } : {}), }; @@ -272,7 +274,10 @@ export function parseDamlJsBundleConfig(raw: unknown, label: string): DamlJsBund } const generatedJsDir = raw['generatedJsDir']; - if (generatedJsDir !== undefined && (typeof generatedJsDir !== 'string' || generatedJsDir.length === 0)) { + if ( + generatedJsDir !== undefined && + (typeof generatedJsDir !== 'string' || generatedJsDir.length === 0) + ) { throw new Error(`Invalid ${label}.generatedJsDir`); } diff --git a/src/daml/codegen/verify-package-imports.ts b/src/daml/codegen/verify-package-imports.ts index c2cc770..1ec9c52 100644 --- a/src/daml/codegen/verify-package-imports.ts +++ b/src/daml/codegen/verify-package-imports.ts @@ -63,7 +63,9 @@ export function findUnresolvedPackageImports( } /** Verify published lib has no unresolved daml.js / @fairmint codegen imports. */ -export function verifyPackageImports(options: VerifyPackageImportsOptions): UnresolvedImportIssue[] { +export function verifyPackageImports( + options: VerifyPackageImportsOptions +): UnresolvedImportIssue[] { const libDir = path.resolve(options.libDir); console.log(`🔍 Checking for unresolved daml.js/ imports in ${libDir}...\n`); diff --git a/src/prepare-release.ts b/src/prepare-release.ts index 06d9df4..cc7b541 100644 --- a/src/prepare-release.ts +++ b/src/prepare-release.ts @@ -176,7 +176,9 @@ export function resolvePublishedNpmState(packageName: string): { throw new Error( `Unable to determine published versions for ${packageName} (failing closed). ` + `registry: ${registry.message}; npm view: ${ - npmView.kind === 'error' ? npmView.message : 'package not found (ambiguous after registry error)' + npmView.kind === 'error' + ? npmView.message + : 'package not found (ambiguous after registry error)' }` ); } diff --git a/test/unit/daml/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts index 3c1d3dc..61c5a65 100644 --- a/test/unit/daml/bundle-dependencies.test.ts +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -1,11 +1,4 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -24,10 +17,7 @@ function writePair(dir: string, base: string, js: string, dts = 'export {};\n'): function scaffoldRepo(): string { const root = mkdtempSync(join(tmpdir(), 'daml-js-bundle-')); - writeFileSync( - join(root, 'multi-package.yaml'), - `packages:\n- DemoPkg\n` - ); + writeFileSync(join(root, 'multi-package.yaml'), `packages:\n- DemoPkg\n`); mkdirSync(join(root, 'DemoPkg'), { recursive: true }); writeFileSync( join(root, 'DemoPkg', 'daml.yaml'), @@ -81,7 +71,8 @@ export { Demo } ; name: '@fairmint/demo-daml-js', version: '0.0.1', dependencies: { - 'daml.js/ghc-stdlib-DA-Internal-Template-1.0.0': 'file:../ghc-stdlib-DA-Internal-Template-1.0.0', + 'daml.js/ghc-stdlib-DA-Internal-Template-1.0.0': + 'file:../ghc-stdlib-DA-Internal-Template-1.0.0', 'daml.js/daml-stdlib-DA-Time-Types-1.0.0': 'file:../daml-stdlib-DA-Time-Types-1.0.0', }, }, @@ -155,9 +146,9 @@ describe('daml-js-bundle config', (): void => { }); it('rejects unknown preset ids', (): void => { - expect(() => - parseDamlJsBundleConfig({ presets: ['wrapped-assets'] }, 'test') - ).toThrow(/Invalid test.presets\[0\]/); + expect(() => parseDamlJsBundleConfig({ presets: ['wrapped-assets'] }, 'test')).toThrow( + /Invalid test.presets\[0\]/ + ); }); it('rejects unsafe rootIndex.outputDir and copy paths', (): void => { @@ -265,9 +256,9 @@ describe('bundleDependenciesForTarget', (): void => { expect(applied).toEqual(['da-internal-template', 'da-time-types']); expect(existsSync(join(pkgDir, 'lib/DA/Internal/Template/module.js'))).toBe(true); - expect(existsSync(join(pkgDir, 'lib/__bundled__/ghc-stdlib-DA-Internal-Template/index.js'))).toBe( - true - ); + expect( + existsSync(join(pkgDir, 'lib/__bundled__/ghc-stdlib-DA-Internal-Template/index.js')) + ).toBe(true); expect(existsSync(join(pkgDir, 'lib/DA/Time/Types/module.js'))).toBe(true); const demoJs = readFileSync(join(pkgDir, 'lib/Demo/module.js'), 'utf8'); @@ -278,7 +269,9 @@ describe('bundleDependenciesForTarget', (): void => { const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as { dependencies?: Record; }; - expect(pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0']).toBeUndefined(); + expect( + pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0'] + ).toBeUndefined(); expect(pkgJson.dependencies?.['daml.js/daml-stdlib-DA-Time-Types-1.0.0']).toBeUndefined(); const mainIndex = readFileSync(join(pkgDir, 'lib/index.js'), 'utf8'); @@ -352,7 +345,9 @@ require('daml.js/splice-api-featured-app-v2-1.0.0'); expect(pkgJson.dependencies?.['daml.js/daml-stdlib-DA-Time-Types-1.0.0']).toBe( 'file:../daml-stdlib-DA-Time-Types-1.0.0' ); - expect(pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0']).toBeUndefined(); + expect( + pkgJson.dependencies?.['daml.js/ghc-stdlib-DA-Internal-Template-1.0.0'] + ).toBeUndefined(); } finally { rmSync(root, { recursive: true, force: true }); } @@ -366,10 +361,7 @@ require('daml.js/splice-api-featured-app-v2-1.0.0'); // Only materialize HoldingV1 — leave the other five token packages missing. writePair( - join( - root, - 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1' - ), + join(root, 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1'), 'module', `"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); @@ -378,10 +370,7 @@ exports.Holding = {}; `export declare const Holding: object;\n` ); writePair( - join( - root, - 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1' - ), + join(root, 'generated/js/splice-api-token-holding-v1-1.0.0/lib/Splice/Api/Token/HoldingV1'), 'index', `"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/test/unit/daml/codegen.test.ts b/test/unit/daml/codegen.test.ts index 2d6a308..70ebcac 100644 --- a/test/unit/daml/codegen.test.ts +++ b/test/unit/daml/codegen.test.ts @@ -1,11 +1,4 @@ -import { - mkdtempSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, - existsSync, -} from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -24,7 +17,11 @@ import { fixSpliceRefs, updateGeneratedPackages, } from '../../../src/daml/codegen'; -import { parseChangelogRepo, parseVersion, selectReleaseVersion } from '../../../src/prepare-release'; +import { + parseChangelogRepo, + parseVersion, + selectReleaseVersion, +} from '../../../src/prepare-release'; describe('collapseManifestLines', (): void => { it('drops map files and collapses js/d.ts pairs', (): void => { @@ -79,7 +76,7 @@ describe('generated output helpers', (): void => { source.replace(/daml\.js\/foo/g, ctx.isDts ? './rel' : './rel') ); expect(rewritten).toBe(2); - expect(readFileSync(join(dir, 'mod.js'), 'utf8')).toContain("./rel"); + expect(readFileSync(join(dir, 'mod.js'), 'utf8')).toContain('./rel'); writeGeneratedOutputPair(dir, 'other', { js: "require('@fairmint/splice-api-token-metadata-v1-1.0.0');\n", diff --git a/test/unit/daml/upgrade-and-policy.test.ts b/test/unit/daml/upgrade-and-policy.test.ts index c5a27d9..0e97a60 100644 --- a/test/unit/daml/upgrade-and-policy.test.ts +++ b/test/unit/daml/upgrade-and-policy.test.ts @@ -352,9 +352,9 @@ describe('dar version policy extra watch paths', (): void => { it('parses --extra-policy-paths as CSV and/or repeatable flags', (): void => { expect(parseExtraPolicyPathsArg(['--all'])).toBeUndefined(); - expect(parseExtraPolicyPathsArg(['--extra-policy-paths', 'scripts/codegen,libs/splice'])).toEqual( - ['scripts/codegen', 'libs/splice'] - ); + expect( + parseExtraPolicyPathsArg(['--extra-policy-paths', 'scripts/codegen,libs/splice']) + ).toEqual(['scripts/codegen', 'libs/splice']); expect( parseExtraPolicyPathsArg([ '--extra-policy-paths', diff --git a/test/unit/scripts/canton-dev-tools.test.ts b/test/unit/scripts/canton-dev-tools.test.ts index f8df7bb..70edb5f 100644 --- a/test/unit/scripts/canton-dev-tools.test.ts +++ b/test/unit/scripts/canton-dev-tools.test.ts @@ -225,9 +225,13 @@ describe('canton-dev-tools DAML command dispatch', (): void => { }); expect(codegen).toBe('codegen-js --root /tmp'); - const release = execFileSync(localnetBin, ['prepare-release', '--changelog-repo', 'Fairmint/canton-assets'], { - encoding: 'utf8', - }); + const release = execFileSync( + localnetBin, + ['prepare-release', '--changelog-repo', 'Fairmint/canton-assets'], + { + encoding: 'utf8', + } + ); expect(release).toBe('prepare-release --changelog-repo Fairmint/canton-assets'); } finally { rmSync(packageRoot, { recursive: true, force: true });