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/README.md b/README.md index 2e008ba..8ec7c17 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,175 @@ 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 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 ``` `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) + +### Phase 2: `bundle-dependencies` + `create-root-index` + +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 +{ + "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" + ] + } +} +``` + +Or point at the file from `package.json`: + +```json +{ + "cantonDevTools": { + "damlJsBundle": "./daml-js-bundle.json" + } +} +``` + +Library imports: + +```ts +import { + runCodegenJs, + bundleDependencies, + createRootIndex, + fixSpliceRefs, + resolveDamlJsBundleConfig, + BUNDLE_PRESET_IDS, +} 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 && 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 + } + } +} +``` + +`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 By default, auto-selection only treats package `daml.yaml` / `daml/` sources and `dars//` @@ -97,7 +262,11 @@ 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..616ab18 100755 --- a/bin/canton-dev-tools +++ b/bin/canton-dev-tools @@ -49,10 +49,19 @@ 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] + bundle-dependencies [--root ] [--config ] + create-root-index [--root ] [--config ] + fix-splice-refs [--root ] [--target ] + 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 + 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 @@ -212,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) + 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/package.json b/package.json index 0750c7a..f02cd1b 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": "tsc -p tsconfig.json", "prepare-release": "tsx scripts/prepare-release.ts", "prepublishOnly": "npm run prepack", "test": "npm run -s typecheck && jest", diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index e2e0a45..0e92111 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -1,290 +1,26 @@ #!/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 { + compareVersions, + parseChangelogRepo, + parseVersion, + pickLatestSemver, + prepareRelease, + selectReleaseVersion, +} from '../src/prepare-release'; diff --git a/src/cli.ts b/src/cli.ts index 33213cb..a257d91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,18 +1,24 @@ -#!/usr/bin/env node /** * CLI entry for DAML package tooling subcommands. * * 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 { 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'; import { runVerifyDarsCli } from './daml/verify-dars'; +import { runPrepareReleaseCli } from './prepare-release'; function usage(): void { console.log(`Usage: canton-dev-tools [options] @@ -25,9 +31,27 @@ 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 + 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) check-dar-version-policy options: --all Check every managed package @@ -44,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') { @@ -77,6 +111,31 @@ function main(): void { runSyncSpliceDarsCli(args); break; } + case 'codegen-js': { + 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; + } + 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/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 new file mode 100644 index 0000000..5f62c32 --- /dev/null +++ b/src/daml/codegen/bundle-dependencies.ts @@ -0,0 +1,384 @@ +/** + * 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; + } + + 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); + // 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); + 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..d25a037 --- /dev/null +++ b/src/daml/codegen/bundle-presets.ts @@ -0,0 +1,695 @@ +/** + * 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; + /** + * 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[]; +} + +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'); + return true; + }, + 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'); + return true; + }, + 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 false; + } + 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'); + return true; + }, + 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 false; + } + console.log('✅ Copied splice-amulet Splice modules'); + return true; + }, + 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 false; + } + 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'); + return true; + }, + 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 false; + } + 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'); + return true; + }, + 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 false; + } + 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'); + return true; + }, + 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...'); + 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}`); + copied.push(pkg); + } else { + console.log(`⚠️ ${pkg.wrapperName} not found at ${sourceDir}`); + } + } + + if (copied.length === 0) { + console.log('⚠️ No splice-api-token-v1 source packages found'); + return false; + } + + 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, + 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 } } }; +` + ); + } + 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), + }) + ), + }, + + '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 false; + } + } + 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'); + return true; + }, + 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 new file mode 100644 index 0000000..86610f0 --- /dev/null +++ b/src/daml/codegen/codegen-js.ts @@ -0,0 +1,151 @@ +/** + * 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. + * + * Phase 2 (config-driven, separate CLI): + * - bundle-dependencies + * - create-root-index + * - fix-splice-refs --target + */ + +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.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!, + 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(', ')}. ` + + 'Next: canton-dev-tools bundle-dependencies → create-root-index → ' + + 'fix-splice-refs --target → 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..00ddeee --- /dev/null +++ b/src/daml/codegen/collapse-manifest.ts @@ -0,0 +1,54 @@ +/** + * 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')) { + 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); + } + } + + 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/create-root-index.ts b/src/daml/codegen/create-root-index.ts new file mode 100644 index 0000000..5eaf02d --- /dev/null +++ b/src/daml/codegen/create-root-index.ts @@ -0,0 +1,293 @@ +/** + * 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 { + assertSafeRelativePath, + normalizeRelativePath, + resolveContainedPath, +} from '../sync-splice-dars'; +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. + * + * `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, + options: { + generatedJsDir: string; + pins: ResolvedDamlJsBundleConfig['pins']; + presets: BundlePresetId[]; + } +): number { + 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) + ); + 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 = 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 [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 ?? []; + // 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: presetsToPatch, + }); + } + + ensureBundledDANamespaceIndexes(packageRoot); + ensureBundledSpliceNamespaceIndexes(packageRoot); + + 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..0d7c122 --- /dev/null +++ b/src/daml/codegen/daml-js-bundle-config.ts @@ -0,0 +1,407 @@ +/** + * 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`). + * 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; + /** 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 copy = (copyRaw as string[]).map((entry, index) => { + assertSafeRelativePath(entry, `${label}.copy[${index}]`); + return normalizeRelativePath(entry); + }); + + 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 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']; + 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, + 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/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..f20933f --- /dev/null +++ b/src/daml/codegen/generated-output-helpers.ts @@ -0,0 +1,196 @@ +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); + // Preserve whichever quote style the generated import used. + if (isDts) { + return source.replace( + new RegExp(`from (['"])${escapedImportPath}\\1;`, 'g'), + `from $1${relativePath}$1;` + ); + } + + return source.replace( + new RegExp(`require\\((['"])${escapedImportPath}\\1\\)`, 'g'), + `require($1${relativePath}$1)` + ); +} + +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..920713c --- /dev/null +++ b/src/daml/codegen/index.ts @@ -0,0 +1,17 @@ +/** Generic DAML → npm JS bindings helpers (Phase 1 + Phase 2). */ + +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'; +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/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..1ec9c52 --- /dev/null +++ b/src/daml/codegen/verify-package-imports.ts @@ -0,0 +1,105 @@ +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..cc7b541 --- /dev/null +++ b/src/prepare-release.ts @@ -0,0 +1,436 @@ +/** + * 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 { execFileSync, 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; +} + +/** 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 { + execFileSync('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'); +} + +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). + */ +export function getNpmMetadataFromRegistry(packageName: string): NpmLookupResult { + try { + const encodedName = encodePackageNameForRegistry(packageName); + 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 { kind: 'found', latest, versions }; + } catch (error) { + return { kind: 'error', message: getErrorMessage(error) }; + } +} + +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 versionsRaw = execSync(`npm view "${packageName}" versions --json`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + 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 { + // Semver compare — lexicographic sort ranks "0.9.0" above "0.10.0". + latest = pickLatestSemver(versions); + } + + return { kind: 'found', latest, versions }; + } catch (error) { + const message = getErrorMessage(error); + if (isNpmNotFoundMessage(message)) { + return { kind: 'not-found' }; + } + return { kind: 'error', message }; + } +} + +/** + * 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() }; + } + + 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 }; + } + + 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)' + }` + ); +} + +/** 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: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +/** Compare two parsed semantic versions. */ +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, + 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; + 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)) { + 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; + + // 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( + packageJson: PackageJson, + explicit?: string +): string | undefined { + if (explicit) return explicit; + 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). + */ +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...'); + 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)'); + } + + 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'); + + 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; + try { + commits = readCommitsSince(rootDir, lastTag); + } catch { + commits = ''; + } + + 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/bundle-dependencies.test.ts b/test/unit/daml/bundle-dependencies.test.ts new file mode 100644 index 0000000..61c5a65 --- /dev/null +++ b/test/unit/daml/bundle-dependencies.test.ts @@ -0,0 +1,556 @@ +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('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 { + 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 }); + } + }); + + 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 }); + } + }); + + 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 => { + 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 }); + } + }); + + 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 }); + } + }); +}); diff --git a/test/unit/daml/codegen.test.ts b/test/unit/daml/codegen.test.ts new file mode 100644 index 0000000..70ebcac --- /dev/null +++ b/test/unit/daml/codegen.test.ts @@ -0,0 +1,275 @@ +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, + parseVersion, + 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('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/); + }); +}); + +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 }); + } + }); + + 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 => { + 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 pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 = require('@fairmint/splice-api-token-metadata-v1-1.0.0');", + 'exports.x = pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.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( + 'pkgabcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.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('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/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 8b6b8dc..70edb5f 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,20 @@ 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 }); } diff --git a/test/unit/scripts/prepare-release.test.ts b/test/unit/scripts/prepare-release.test.ts index 5e10648..d0e6700 100644 --- a/test/unit/scripts/prepare-release.test.ts +++ b/test/unit/scripts/prepare-release.test.ts @@ -1,4 +1,9 @@ -import { selectReleaseVersion } from '../../../scripts/prepare-release'; +import { + selectReleaseVersion, + parseVersion, + parseChangelogRepo, + pickLatestSemver, +} from '../../../scripts/prepare-release'; describe('selectReleaseVersion', (): void => { const withTakenVersions = (...versions: string[]) => { @@ -32,4 +37,27 @@ 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'); + }); +}); + +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'); + }); });