diff --git a/README.md b/README.md index 6a64a69..2e008ba 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,42 @@ npx canton-dev-tools prepare-build npx canton-dev-tools verify-dars npx canton-dev-tools backup-dar --package WrappedAssets-v01 --version 0.0.1 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 ``` `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`). +### `check-dar-version-policy` extra watch paths + +By default, auto-selection only treats package `daml.yaml` / `daml/` sources and `dars//` +backups (plus lock-entry diffs) as package input changes. Repos such as OCP also need shared +inputs (`scripts/codegen/`, `libs/splice/`) to select packages. Configure extra relative prefixes +with this precedence (first wins): + +1. CLI `--extra-policy-paths ` (repeatable; overrides config entirely, including `[]`) +2. `package.json` → `cantonDevTools.darVersionPolicyWatchPaths` +3. repo-root `canton-daml-tooling.json` → `darVersionPolicyWatchPaths` +4. `[]` (no extra watches) + +```json +{ + "cantonDevTools": { + "darVersionPolicyWatchPaths": ["scripts/codegen", "libs/splice"] + } +} +``` + +```json +{ + "darVersionPolicyWatchPaths": ["scripts/codegen/", "libs/splice/"] +} +``` + +Paths must be relative and contained (no `..` / absolute escapes). A change under any configured +prefix selects **all** managed packages for the policy check. + ### `sync-splice-dars` config By default, sync uses the packaged pin at `config/default-splice-dars.json` (MainNet Splice diff --git a/bin/canton-dev-tools b/bin/canton-dev-tools index 19578d8..aa2bda9 100755 --- a/bin/canton-dev-tools +++ b/bin/canton-dev-tools @@ -45,7 +45,7 @@ DAML package commands (from a multi-package repo root): prepare-build verify-dars [--update] backup-dar --package --version - check-dar-version-policy [--all] [--package ] + check-dar-version-policy [--all] [--package ] [--extra-policy-paths ] check-upgrade-compat sync-splice-dars [--config ] [--force] install-dpm-sdks diff --git a/package-lock.json b/package-lock.json index 5c42921..d5137c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fairmint/canton-dev-tools", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fairmint/canton-dev-tools", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "yaml": "2.9.0" diff --git a/package.json b/package.json index 08edba5..0750c7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fairmint/canton-dev-tools", - "version": "0.1.4", + "version": "0.1.5", "description": "Shared LocalNet CLI, DAML package tooling, and Canton integration-test helpers for Fairmint", "keywords": [ "canton", diff --git a/src/cli.ts b/src/cli.ts index 928d50c..33213cb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,13 @@ DAML package commands (run from a multi-package repo root): Common options: --root Repo root (default: cwd) +check-dar-version-policy options: + --all Check every managed package + --package Check one package + --base Diff base (default: origin/main) + --extra-policy-paths Extra watch prefixes (repeatable / CSV); overrides config + --deployment DevNet/MainNet preflight (requires --package) + LocalNet commands are handled by the canton-dev-tools shell binary. `); } diff --git a/src/daml/check-dar-version-policy.ts b/src/daml/check-dar-version-policy.ts index a5c8cbb..3e0f7f6 100644 --- a/src/daml/check-dar-version-policy.ts +++ b/src/daml/check-dar-version-policy.ts @@ -34,7 +34,11 @@ import { type DeploymentNetwork, } from './dar-version-policy'; import { discoverManagedPackages, requirePackage, type PackageConfig } from './packages'; -import { resolveContainedPath } from './sync-splice-dars'; +import { + assertSafeRelativePath, + normalizeRelativePath, + resolveContainedPath, +} from './sync-splice-dars'; export interface CheckDarVersionPolicyOptions { rootDir: string; @@ -42,6 +46,126 @@ export interface CheckDarVersionPolicyOptions { base?: string; deployment?: DeploymentNetwork; packageKey?: string; + /** + * Extra relative prefixes that count as package input changes for auto-selection. + * When omitted (`undefined`), paths are loaded from package.json / + * `canton-daml-tooling.json`. Pass an explicit array (including `[]`) to override. + */ + extraPolicyPaths?: string[]; +} + +const PACKAGE_JSON_WATCH_PATHS_LABEL = 'package.json cantonDevTools.darVersionPolicyWatchPaths'; +const TOOLING_JSON_WATCH_PATHS_LABEL = 'canton-daml-tooling.json darVersionPolicyWatchPaths'; +const CLI_WATCH_PATHS_LABEL = '--extra-policy-paths'; + +/** Normalize, validate, and dedupe relative watch prefixes (reject escapes). */ +export function normalizeExtraPolicyWatchPaths( + paths: readonly string[], + label = 'darVersionPolicyWatchPaths' +): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const raw of paths) { + if (typeof raw !== 'string') { + throw new Error(`Invalid ${label} entry (expected string): ${String(raw)}`); + } + const trimmed = raw.trim(); + if (!trimmed) continue; + // Config often uses directory prefixes with a trailing slash (`scripts/codegen/`). + const withoutTrailingSlash = trimmed.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!withoutTrailingSlash) { + throw new Error(`Unsafe ${label}: ${raw}`); + } + assertSafeRelativePath(withoutTrailingSlash, label); + const prefix = normalizeRelativePath(withoutTrailingSlash); + if (!prefix) { + throw new Error(`Unsafe ${label}: ${raw}`); + } + // Ensure the normalized form still cannot escape (e.g. after collapsing `.`). + assertSafeRelativePath(prefix, label); + if (seen.has(prefix)) continue; + seen.add(prefix); + normalized.push(prefix); + } + return normalized; +} + +function readStringArrayField(value: unknown, label: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) { + throw new Error(`Invalid ${label} (expected string[])`); + } + return value; +} + +function loadWatchPathsFromPackageJson(rootDir: string): string[] | undefined { + const packageJsonPath = path.join(rootDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return undefined; + const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid package.json (expected object): ${packageJsonPath}`); + } + const cantonDevTools = Reflect.get(parsed, 'cantonDevTools'); + if (cantonDevTools === undefined) return undefined; + if (typeof cantonDevTools !== 'object' || cantonDevTools === null || Array.isArray(cantonDevTools)) { + throw new Error(`Invalid package.json cantonDevTools (expected object): ${packageJsonPath}`); + } + return readStringArrayField( + Reflect.get(cantonDevTools, 'darVersionPolicyWatchPaths'), + PACKAGE_JSON_WATCH_PATHS_LABEL + ); +} + +function loadWatchPathsFromToolingJson(rootDir: string): string[] | undefined { + const toolingPath = path.join(rootDir, 'canton-daml-tooling.json'); + if (!fs.existsSync(toolingPath)) return undefined; + const parsed: unknown = JSON.parse(fs.readFileSync(toolingPath, 'utf8')); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid canton-daml-tooling.json (expected object): ${toolingPath}`); + } + return readStringArrayField( + Reflect.get(parsed, 'darVersionPolicyWatchPaths'), + TOOLING_JSON_WATCH_PATHS_LABEL + ); +} + +/** + * Resolve extra DAR policy watch paths. + * Precedence: explicit `cliPaths` → `package.json` `cantonDevTools.darVersionPolicyWatchPaths` + * → `canton-daml-tooling.json` → `[]`. + */ +export function resolveDarVersionPolicyWatchPaths( + rootDir: string, + cliPaths?: readonly string[] +): string[] { + if (cliPaths !== undefined) { + return normalizeExtraPolicyWatchPaths(cliPaths, CLI_WATCH_PATHS_LABEL); + } + const fromPackageJson = loadWatchPathsFromPackageJson(rootDir); + if (fromPackageJson !== undefined) { + return normalizeExtraPolicyWatchPaths(fromPackageJson, PACKAGE_JSON_WATCH_PATHS_LABEL); + } + const fromToolingJson = loadWatchPathsFromToolingJson(rootDir); + if (fromToolingJson !== undefined) { + return normalizeExtraPolicyWatchPaths(fromToolingJson, TOOLING_JSON_WATCH_PATHS_LABEL); + } + return []; +} + +/** True when `changedPath` equals or is nested under a normalized watch prefix. */ +export function pathMatchesWatchPrefix(changedPath: string, prefix: string): boolean { + const normalizedChanged = changedPath.replace(/\\/g, '/'); + return normalizedChanged === prefix || normalizedChanged.startsWith(`${prefix}/`); +} + +function changedPathTouchesExtraWatchPaths( + changedPaths: readonly string[], + extraWatchPaths: readonly string[] +): boolean { + if (extraWatchPaths.length === 0) return false; + return changedPaths.some((changedPath) => + extraWatchPaths.some((prefix) => pathMatchesWatchPrefix(changedPath, prefix)) + ); } interface TagRef { @@ -197,12 +321,18 @@ function freshAndLockedEntry( return { key, entry, hash }; } -function changedPackages( +/** + * Select managed packages whose inputs changed vs `base`. + * Extra watch prefixes (codegen, splice libs, …) count as shared package inputs — + * a touch under any of them selects every managed package. + */ +export function selectChangedPackages( rootDir: string, base: string, currentLock: DarsLock, baseLock: DarsLock, - allPackages: PackageConfig[] + allPackages: PackageConfig[], + extraWatchPaths: readonly string[] = [] ): PackageConfig[] { const changedPaths = gitText(rootDir, [ 'diff', @@ -212,6 +342,9 @@ function changedPackages( ]) .split('\n') .filter(Boolean); + if (changedPathTouchesExtraWatchPaths(changedPaths, extraWatchPaths)) { + return [...allPackages]; + } const lockChanged = changedPaths.includes('dars/dars.lock'); return allPackages.filter((pkg) => { const filesChanged = changedPaths.some( @@ -454,6 +587,7 @@ function deploymentPreflight( export function checkDarVersionPolicy(options: CheckDarVersionPolicyOptions): void { const rootDir = path.resolve(options.rootDir); const allPackages = discoverManagedPackages(rootDir); + const extraWatchPaths = resolveDarVersionPolicyWatchPaths(rootDir, options.extraPolicyPaths); if (options.deployment) { if (!options.packageKey) throw new Error('--deployment requires --package'); @@ -474,7 +608,14 @@ export function checkDarVersionPolicy(options: CheckDarVersionPolicyOptions): vo } else if (options.all) { packages = allPackages; } else { - packages = changedPackages(rootDir, base, currentLock, baseLock, allPackages); + packages = selectChangedPackages( + rootDir, + base, + currentLock, + baseLock, + allPackages, + extraWatchPaths + ); } for (const pkg of packages) { if (!parseStrictSemver(pkg.version)) { @@ -501,6 +642,38 @@ export function checkDarVersionPolicy(options: CheckDarVersionPolicyOptions): vo for (const pkg of packages) validatePackage(rootDir, pkg, currentLock, baseLock, base, tagNames); } +/** Parse `--extra-policy-paths` (CSV and/or repeatable). `undefined` when the flag is absent. */ +export function parseExtraPolicyPathsArg(args: readonly string[]): string[] | undefined { + const collected: string[] = []; + let seen = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) continue; + if (arg === '--extra-policy-paths') { + seen = true; + const value = args[++index] ?? ''; + collected.push( + ...value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + ); + continue; + } + if (arg.startsWith('--extra-policy-paths=')) { + seen = true; + collected.push( + ...arg + .slice('--extra-policy-paths='.length) + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + ); + } + } + return seen ? collected : undefined; +} + export function runCheckDarVersionPolicyCli(args: string[] = process.argv.slice(2)): void { try { const options: CheckDarVersionPolicyOptions = { @@ -508,6 +681,10 @@ export function runCheckDarVersionPolicyCli(args: string[] = process.argv.slice( all: args.includes('--all'), base: 'origin/main', }; + const extraPolicyPaths = parseExtraPolicyPathsArg(args); + if (extraPolicyPaths !== undefined) { + options.extraPolicyPaths = extraPolicyPaths; + } for (let index = 0; index < args.length; index += 1) { if (args[index] === '--root') options.rootDir = args[++index] ?? options.rootDir; if (args[index] === '--base') options.base = args[++index] ?? ''; @@ -518,6 +695,10 @@ export function runCheckDarVersionPolicyCli(args: string[] = process.argv.slice( options.deployment = network; } if (args[index] === '--package') options.packageKey = args[++index]; + if (args[index] === '--extra-policy-paths') { + // Value consumed by parseExtraPolicyPathsArg; skip the following token here. + index += 1; + } } if (!options.base) throw new Error('--base requires a Git ref'); checkDarVersionPolicy(options); diff --git a/test/unit/daml/upgrade-and-policy.test.ts b/test/unit/daml/upgrade-and-policy.test.ts index 01bac26..c5a27d9 100644 --- a/test/unit/daml/upgrade-and-policy.test.ts +++ b/test/unit/daml/upgrade-and-policy.test.ts @@ -2,8 +2,19 @@ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { checkDarVersionPolicy, checkUpgradeCompatibility, saveDarsLock } from '../../../src/daml'; +import { dirname, join } from 'node:path'; +import { + checkDarVersionPolicy, + checkUpgradeCompatibility, + normalizeExtraPolicyWatchPaths, + parseExtraPolicyPathsArg, + pathMatchesWatchPrefix, + resolveDarVersionPolicyWatchPaths, + saveDarsLock, + selectChangedPackages, + loadDarsLock, + discoverManagedPackages, +} from '../../../src/daml'; function sha256(content: string | Buffer): string { return createHash('sha256').update(content).digest('hex'); @@ -264,3 +275,150 @@ describe('checkDarVersionPolicy --package', (): void => { ).toThrow(/invalid version/); }); }); + +describe('dar version policy extra watch paths', (): void => { + let rootDir = ''; + + beforeEach((): void => { + rootDir = mkdtempSync(join(tmpdir(), 'canton-dev-tools-policy-watch-')); + writePackage(rootDir, 'WrappedAssets-v01', 'WrappedAssets-v01', '0.0.1'); + writeFileSync(join(rootDir, 'multi-package.yaml'), `packages:\n - WrappedAssets-v01\n`); + mkdirSync(join(rootDir, 'dars'), { recursive: true }); + saveDarsLock(rootDir, { version: 1, packages: {} }); + writeFileSync( + join(rootDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '0.0.0' }, null, 2) + ); + + git(rootDir, ['init']); + git(rootDir, ['config', 'user.email', 'test@example.com']); + git(rootDir, ['config', 'user.name', 'Test']); + git(rootDir, ['add', '.']); + git(rootDir, ['commit', '-m', 'init']); + git(rootDir, ['branch', '-M', 'main']); + }); + + afterEach((): void => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + function baseSha(): string { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: rootDir, encoding: 'utf8' }).trim(); + } + + function commitPath(relativePath: string, contents: string): void { + const fullPath = join(rootDir, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); + git(rootDir, ['add', relativePath]); + git(rootDir, ['commit', '-m', `add ${relativePath}`]); + } + + it('normalizes prefixes, matches nested files, and rejects escapes', (): void => { + expect(normalizeExtraPolicyWatchPaths(['scripts/codegen/', './libs/splice'])).toEqual([ + 'scripts/codegen', + 'libs/splice', + ]); + expect(pathMatchesWatchPrefix('scripts/codegen/generate.ts', 'scripts/codegen')).toBe(true); + expect(pathMatchesWatchPrefix('scripts/codegen', 'scripts/codegen')).toBe(true); + expect(pathMatchesWatchPrefix('scripts/codegen-other/x.ts', 'scripts/codegen')).toBe(false); + expect(() => normalizeExtraPolicyWatchPaths(['../escape'])).toThrow(/Unsafe/); + expect(() => normalizeExtraPolicyWatchPaths(['/abs/path'])).toThrow(/Unsafe/); + }); + + it('loads watch paths with CLI → package.json → canton-daml-tooling.json precedence', (): void => { + writeFileSync( + join(rootDir, 'canton-daml-tooling.json'), + JSON.stringify({ darVersionPolicyWatchPaths: ['from-tooling'] }) + ); + expect(resolveDarVersionPolicyWatchPaths(rootDir)).toEqual(['from-tooling']); + + writeFileSync( + join(rootDir, 'package.json'), + JSON.stringify({ + name: 'fixture', + cantonDevTools: { darVersionPolicyWatchPaths: ['from-package-json', 'libs/splice/'] }, + }) + ); + expect(resolveDarVersionPolicyWatchPaths(rootDir)).toEqual([ + 'from-package-json', + 'libs/splice', + ]); + expect(resolveDarVersionPolicyWatchPaths(rootDir, ['scripts/codegen'])).toEqual([ + 'scripts/codegen', + ]); + expect(resolveDarVersionPolicyWatchPaths(rootDir, [])).toEqual([]); + }); + + 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', + '--extra-policy-paths', + 'libs/splice', + ]) + ).toEqual(['scripts/codegen', 'libs/splice']); + expect(parseExtraPolicyPathsArg(['--extra-policy-paths=a,b'])).toEqual(['a', 'b']); + }); + + it('selects packages for codegen-only diffs when scripts/codegen is watched', (): void => { + const base = baseSha(); + commitPath('scripts/codegen/generate-captable.ts', 'export {};\n'); + + const packages = discoverManagedPackages(rootDir); + const lock = loadDarsLock(rootDir); + expect( + selectChangedPackages(rootDir, base, lock, lock, packages, []).map((pkg) => pkg.name) + ).toEqual([]); + expect( + selectChangedPackages(rootDir, base, lock, lock, packages, ['scripts/codegen']).map( + (pkg) => pkg.name + ) + ).toEqual(['WrappedAssets-v01']); + + expect(() => checkDarVersionPolicy({ rootDir, base })).not.toThrow(); + expect(() => + checkDarVersionPolicy({ rootDir, base, extraPolicyPaths: ['scripts/codegen'] }) + ).toThrow(/Current package is not backed up/); + }); + + it('selects packages for libs-only diffs when libs/splice is watched', (): void => { + const base = baseSha(); + commitPath('libs/splice/daml/dars/splice-amulet-0.1.16.dar', 'dar-bytes'); + + const packages = discoverManagedPackages(rootDir); + const lock = loadDarsLock(rootDir); + expect( + selectChangedPackages(rootDir, base, lock, lock, packages, []).map((pkg) => pkg.name) + ).toEqual([]); + expect( + selectChangedPackages(rootDir, base, lock, lock, packages, ['libs/splice']).map( + (pkg) => pkg.name + ) + ).toEqual(['WrappedAssets-v01']); + + writeFileSync( + join(rootDir, 'canton-daml-tooling.json'), + JSON.stringify({ darVersionPolicyWatchPaths: ['libs/splice/'] }) + ); + expect(() => checkDarVersionPolicy({ rootDir, base })).toThrow( + /Current package is not backed up/ + ); + }); + + it('rejects escaping watch paths from package.json config', (): void => { + writeFileSync( + join(rootDir, 'package.json'), + JSON.stringify({ + name: 'fixture', + cantonDevTools: { darVersionPolicyWatchPaths: ['../../etc/passwd'] }, + }) + ); + expect(() => resolveDarVersionPolicyWatchPaths(rootDir)).toThrow(/Unsafe/); + }); +});