Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>/`
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 <csv>` (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
Expand Down
2 changes: 1 addition & 1 deletion bin/canton-dev-tools
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ DAML package commands (from a multi-package repo root):
prepare-build
verify-dars [--update]
backup-dar --package <name> --version <ver>
check-dar-version-policy [--all] [--package <name>]
check-dar-version-policy [--all] [--package <name>] [--extra-policy-paths <csv>]
check-upgrade-compat
sync-splice-dars [--config <path>] [--force]
install-dpm-sdks
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ DAML package commands (run from a multi-package repo root):
Common options:
--root <dir> Repo root (default: cwd)

check-dar-version-policy options:
--all Check every managed package
--package <name> Check one package
--base <ref> Diff base (default: origin/main)
--extra-policy-paths <csv> Extra watch prefixes (repeatable / CSV); overrides config
--deployment <net> DevNet/MainNet preflight (requires --package)

LocalNet commands are handled by the canton-dev-tools shell binary.
`);
}
Expand Down
189 changes: 185 additions & 4 deletions src/daml/check-dar-version-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,138 @@ 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;
all?: boolean;
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<string>();
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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand Down Expand Up @@ -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');
Expand All @@ -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)) {
Expand All @@ -501,13 +642,49 @@ 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 = {
rootDir: process.cwd(),
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] ?? '';
Expand All @@ -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);
Expand Down
Loading
Loading