Skip to content

Commit cd73143

Browse files
committed
feat(cli): add --fail-if-modified to hub push
Introduces a shared, key-order-insensitive content comparison (`canonicalEqual` in @finos/calm-shared) and uses it to add a strict, non-bumping `--fail-if-modified` mode to `calm hub push` for architecture, pattern and standard documents: - brand-new mapping -> created at 1.0.0 (unchanged behaviour) - exists + unchanged -> skipped (no new version, file left as-is) - exists + modified -> push fails Default behaviour (auto-bump) is unchanged. The comparison helper is shared so it can be reused by other push paths without duplication. Claude-Session: https://claude.ai/code/session_01QiHAHdi4d4mCoy4wku5s2v
1 parent 6ce8654 commit cd73143

7 files changed

Lines changed: 210 additions & 18 deletions

File tree

cli/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ npm run copy-ai-tools # Copy AI agent files from ../calm-ai/
5959
9. **hub** - Command **group** for interacting with CALM Hub. Subcommands:
6060
- `hub push <resource> <file>` / `hub pull <resource>` / `hub list <resources>` / `hub create <resource>`
6161
- Resources span architectures, patterns, standards, control-requirements, control-configurations, namespaces, and domains (which subcommands exist varies per verb).
62+
- `hub push architecture|pattern|standard` auto-bumps by default (creates a new version off the latest). The `--fail-if-modified` flag switches to a strict, non-bumping mode: a brand-new mapping is still created at `1.0.0`, an unchanged document is skipped, and a document that differs from the latest published version fails the push. Content comparison uses `canonicalEqual` from `@finos/calm-shared` (key-order-insensitive).
6263

6364
### Important Directories
6465
```

cli/src/cli.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,9 @@ Example:
463463
.choices(['patch', 'minor', 'major'])
464464
.default('patch');
465465

466+
const hubFailIfModifiedOption = new Option('--fail-if-modified', 'Strict mode: do not auto-bump. Skip if the document is unchanged from the latest published version; fail if it has changed. A brand-new mapping is still created at 1.0.0.')
467+
.default(false);
468+
466469
const hubCmd = new Command('hub').description('Interact with CALM Hub');
467470

468471
// hub push
@@ -476,14 +479,16 @@ Example:
476479
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
477480
.addOption(hubOutputOption)
478481
.addOption(hubVersionBumpOption)
482+
.addOption(hubFailIfModifiedOption)
479483
.action(async (architectureFile, options) => {
480484
const pushOptions: PushOptions = {
481485
calmHubOptions: { calmHubUrl: options.calmHubUrl },
482486
name: options.name,
483487
description: options.description,
484488
file: architectureFile,
485489
format: options.format,
486-
changeType: options.changeType.toUpperCase() as ResourceChangeType
490+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
491+
failIfModified: options.failIfModified
487492
};
488493
await runPushArchitecture(pushOptions);
489494
});
@@ -496,6 +501,7 @@ Example:
496501
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
497502
.addOption(hubOutputOption)
498503
.addOption(hubVersionBumpOption)
504+
.addOption(hubFailIfModifiedOption)
499505
.action(async (patternFile, options) => {
500506
const pushPatternOptions: PushOptions = {
501507
calmHubOptions: { calmHubUrl: options.calmHubUrl },
@@ -504,6 +510,7 @@ Example:
504510
file: patternFile,
505511
format: options.format,
506512
changeType: options.changeType.toUpperCase() as ResourceChangeType,
513+
failIfModified: options.failIfModified
507514
};
508515
await runPushPattern(pushPatternOptions);
509516
});
@@ -516,6 +523,7 @@ Example:
516523
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
517524
.addOption(hubOutputOption)
518525
.addOption(hubVersionBumpOption)
526+
.addOption(hubFailIfModifiedOption)
519527
.action(async (standardFile, options) => {
520528
const pushStandardOptions: PushOptions = {
521529
calmHubOptions: { calmHubUrl: options.calmHubUrl },
@@ -524,6 +532,7 @@ Example:
524532
file: standardFile,
525533
format: options.format,
526534
changeType: options.changeType.toUpperCase() as ResourceChangeType,
535+
failIfModified: options.failIfModified
527536
};
528537
await runPushStandard(pushStandardOptions);
529538
});

cli/src/command-helpers/hub-commands.spec.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ vi.mock('@finos/calm-shared', async () => {
1616
const documentIdUtils = await vi.importActual<Record<string, unknown>>('@finos/calm-shared/dist/hub/document-id-utils');
1717
// Real (pure) semver helpers used by pushDocument's version-bump path.
1818
const semver = await vi.importActual('@finos/calm-shared/dist/hub/semver');
19+
// Real (pure) canonical-equality helper used by pushDocument's fail-if-modified path.
20+
const canonical = await vi.importActual('@finos/calm-shared/dist/hub/canonical');
1921
const mockClient = {
2022
createNamespace: vi.fn(),
2123
listNamespaces: vi.fn(),
@@ -41,6 +43,7 @@ vi.mock('@finos/calm-shared', async () => {
4143
...documentIdUtils,
4244
extractDocumentMetadata: vi.fn(documentIdUtils['extractDocumentMetadata'] as (...args: unknown[]) => unknown),
4345
...semver,
46+
...canonical,
4447
CalmHubClient: vi.fn(function () { return mockClient; }),
4548
HubClientError: class HubClientError extends Error {
4649
constructor(public status: number, public error: string, public request: string) {
@@ -311,6 +314,74 @@ describe('hub-commands', () => {
311314
0, expect.stringContaining('namespace and mapping'), expect.any(String), 'json'
312315
);
313316
});
317+
318+
describe('--fail-if-modified', () => {
319+
it('creates 1.0.0 for a brand-new mapping even with the flag set', async () => {
320+
const { mockClient } = await getSharedMocks();
321+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue([]);
322+
vi.mocked(mockClient.createMappedResourceVersion).mockResolvedValue(
323+
'http://hub/calm/namespaces/finos/architectures/my-arch/versions/1.0.0'
324+
);
325+
326+
await runPushArchitecture({
327+
calmHubOptions: { calmHubUrl: 'http://hub' },
328+
file: 'arch.json',
329+
failIfModified: true
330+
});
331+
332+
expect(mockClient.getMappedResourceByVersion).not.toHaveBeenCalled();
333+
expect(mockClient.createMappedResourceVersion).toHaveBeenCalledWith(
334+
expect.objectContaining({ version: '1.0.0' }),
335+
expect.any(String)
336+
);
337+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
338+
expect.objectContaining({ status: 'created', version: '1.0.0' })
339+
);
340+
});
341+
342+
it('skips (does not create a new version) when content is unchanged from the latest published version', async () => {
343+
const { mockClient } = await getSharedMocks();
344+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
345+
// Latest published version is byte-identical to the local document (key order ignored).
346+
vi.mocked(mockClient.getMappedResourceByVersion).mockResolvedValue(JSON.parse(pushDoc('my-arch')));
347+
348+
await runPushArchitecture({
349+
calmHubOptions: { calmHubUrl: 'http://hub' },
350+
file: 'arch.json',
351+
failIfModified: true
352+
});
353+
354+
expect(mockClient.getMappedResourceByVersion).toHaveBeenCalledWith('finos', 'my-arch', '1.0.0', 'architectures');
355+
expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled();
356+
expect(fs.writeFile).not.toHaveBeenCalled();
357+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
358+
expect.objectContaining({ status: 'skipped', version: '1.0.0' })
359+
);
360+
});
361+
362+
it('fails when the document has changed relative to the latest published version', async () => {
363+
const { mockClient } = await getSharedMocks();
364+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
365+
// Latest published version differs from the local document.
366+
vi.mocked(mockClient.getMappedResourceByVersion).mockResolvedValue({
367+
$id: 'http://hub/calm/namespaces/finos/architectures/my-arch/versions/1.0.0',
368+
title: 'my-arch',
369+
nodes: [{ 'unique-id': 'added-on-disk' }]
370+
});
371+
372+
await expect(runPushArchitecture({
373+
calmHubOptions: { calmHubUrl: 'http://hub' },
374+
file: 'arch.json',
375+
failIfModified: true
376+
})).rejects.toThrow('process.exit');
377+
378+
expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled();
379+
expect(fs.writeFile).not.toHaveBeenCalled();
380+
expect(hubOutput.printError).toHaveBeenCalledWith(
381+
0, expect.stringContaining('has changed relative to the latest published version'), expect.any(String), 'json'
382+
);
383+
});
384+
});
314385
});
315386

316387
// ── runPullArchitecture ────────────────────────────────────────────────
@@ -517,7 +588,7 @@ describe('hub-commands', () => {
517588
describe('printPushResult', () => {
518589
it('calls printTableSuccess with correct columns when format is pretty', async () => {
519590
printPushResult(
520-
{ mapping: 'my-arch', version: '1.0.0', namespace: 'finos', location: 'http://hub' },
591+
{ status: 'created', mapping: 'my-arch', version: '1.0.0', namespace: 'finos', location: 'http://hub' },
521592
'pretty'
522593
);
523594

@@ -533,8 +604,20 @@ describe('hub-commands', () => {
533604
expect(hubOutput.printJsonSuccess).not.toHaveBeenCalled();
534605
});
535606

607+
it('shows an Unchanged status for a skipped push', async () => {
608+
printPushResult(
609+
{ status: 'skipped', mapping: 'my-arch', version: '1.0.0', namespace: 'finos', location: 'http://hub' },
610+
'pretty'
611+
);
612+
613+
expect(hubOutput.printTableSuccess).toHaveBeenCalledWith(
614+
[{ STATUS: 'Unchanged', MAPPING: 'my-arch', VERSION: '1.0.0', LOCATION: 'http://hub' }],
615+
expect.anything()
616+
);
617+
});
618+
536619
it('calls printJsonSuccess when format is json', async () => {
537-
const result = { mapping: 'my-arch', version: '1.0.0', namespace: 'finos', location: '/loc' };
620+
const result = { status: 'created' as const, mapping: 'my-arch', version: '1.0.0', namespace: 'finos', location: '/loc' };
538621
printPushResult(result, 'json');
539622

540623
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(result);

cli/src/command-helpers/hub-commands.ts

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFile, writeFile } from 'fs/promises';
2-
import { CalmHubClient, CalmHubOptions, HubClientError, HubDomainSummary, HubControlSummary, HubDomainCreateResult, DocumentMetadata, extractDocumentMetadata, computeSemVerBump, sortSemVer, ResourceChangeType, ResourceType, updateDocumentMetadata, constructDocumentId, ControlDocumentMetadata, ControlDocumentKind, extractControlMetadata, updateControlDocumentMetadata } from '@finos/calm-shared';
2+
import { CalmHubClient, CalmHubOptions, HubClientError, HubDomainSummary, HubControlSummary, HubDomainCreateResult, DocumentMetadata, extractDocumentMetadata, computeSemVerBump, sortSemVer, ResourceChangeType, ResourceType, updateDocumentMetadata, constructDocumentId, ControlDocumentMetadata, ControlDocumentKind, extractControlMetadata, updateControlDocumentMetadata, canonicalEqual } from '@finos/calm-shared';
33
import { OutputFormat, parseOutputFormat, printError, printJsonSuccess, printTableSuccess } from './hub-output';
44
import * as cliConfig from '../cli-config';
55

@@ -58,24 +58,41 @@ export interface PushOptions {
5858
version?: string;
5959
format?: string;
6060
changeType?: ResourceChangeType;
61+
/**
62+
* Strict mode. When set, push does not auto-bump: if the mapping already exists, the local
63+
* document is compared to the latest published version. Unchanged content is skipped; changed
64+
* content fails the push (so a merge introduces only the versions it claims). A brand-new
65+
* mapping is still created at 1.0.0.
66+
*/
67+
failIfModified?: boolean;
6168
}
6269

70+
/** Whether a push created a new version or skipped because the content was unchanged. */
71+
export type PushAction = 'created' | 'skipped';
72+
6373
export interface PushResult {
74+
status: PushAction;
6475
version: string;
6576
mapping: string;
6677
namespace: string;
6778
location: string;
6879
}
6980

81+
export interface PushDocumentResult {
82+
action: PushAction;
83+
metadata: DocumentMetadata;
84+
}
85+
7086
/**
7187
* Prints a push operation result in either pretty table or JSON format.
7288
* @param result Push result payload.
7389
* @param format Output format selector.
7490
*/
7591
export function printPushResult(result: PushResult, format: OutputFormat): void {
7692
if (format === 'pretty') {
93+
const statusLabel = result.status === 'created' ? 'Created' : 'Unchanged';
7794
printTableSuccess(
78-
[{ STATUS: 'Created', MAPPING: result.mapping, VERSION: result.version, LOCATION: result.location }],
95+
[{ STATUS: statusLabel, MAPPING: result.mapping, VERSION: result.version, LOCATION: result.location }],
7996
[
8097
{ key: 'STATUS', header: 'STATUS' },
8198
{ key: 'MAPPING', header: 'MAPPING' },
@@ -177,7 +194,7 @@ export async function pushDocument(
177194
fileContent: string,
178195
resourceType: ResourceType,
179196
changeType: ResourceChangeType,
180-
options: PushOptions): Promise<DocumentMetadata> {
197+
options: PushOptions): Promise<PushDocumentResult> {
181198

182199
// allow changing of name/description if not already set.
183200
const name = options.name ?? metadata.name;
@@ -197,6 +214,23 @@ export async function pushDocument(
197214
// Sort defensively so the highest version is last, regardless of the order Hub returns them in.
198215
const sortedVersions = sortSemVer(mappedResourceVersions);
199216
const latestVersion = sortedVersions[sortedVersions.length - 1];
217+
218+
if (options.failIfModified) {
219+
// Strict mode: don't auto-bump. Compare the local document to the latest published
220+
// version — skip when unchanged, fail when it differs.
221+
const latest = await client.getMappedResourceByVersion(namespace, mapping, latestVersion, resourceType);
222+
if (canonicalEqual(JSON.parse(fileContent), latest)) {
223+
newDocumentMetadata.version = latestVersion;
224+
return { action: 'skipped', metadata: newDocumentMetadata };
225+
}
226+
throw new HubCommandError(
227+
0,
228+
`Document '${mapping}' has changed relative to the latest published version (${latestVersion}) in CALM Hub. ` +
229+
'Re-run without --fail-if-modified to publish a new version.',
230+
`push ${resourceType} ${options.file}`
231+
);
232+
}
233+
200234
const newVersion = computeSemVerBump(latestVersion, changeType);
201235
newDocumentMetadata.version = newVersion;
202236
fileContent = updateDocumentMetadata(fileContent, newDocumentMetadata);
@@ -206,7 +240,7 @@ export async function pushDocument(
206240
fileContent = updateDocumentMetadata(fileContent, newDocumentMetadata);
207241
await client.createMappedResourceVersion(newDocumentMetadata, fileContent);
208242
}
209-
return newDocumentMetadata;
243+
return { action: 'created', metadata: newDocumentMetadata };
210244
}
211245

212246
/**
@@ -237,20 +271,24 @@ export async function orchestratePush(options: PushOptions, resourceType: Resour
237271
const namespace = metadata.namespace;
238272
const mapping = metadata.mapping;
239273

240-
let documentMetadata: DocumentMetadata;
241274
try {
242-
documentMetadata = await pushDocument(
243-
client,
244-
namespace,
245-
mapping,
246-
metadata,
247-
fileContent,
248-
resourceType,
249-
options.changeType ?? 'PATCH',
275+
const { action, metadata: documentMetadata } = await pushDocument(
276+
client,
277+
namespace,
278+
mapping,
279+
metadata,
280+
fileContent,
281+
resourceType,
282+
options.changeType ?? 'PATCH',
250283
options);
251-
const newDocument = updateDocumentMetadata(fileContent, documentMetadata);
252-
await writeFile(options.file, newDocument, 'utf-8');
284+
// Only rewrite the on-disk document when a new version was actually created; an unchanged
285+
// document is left exactly as it is.
286+
if (action === 'created') {
287+
const newDocument = updateDocumentMetadata(fileContent, documentMetadata);
288+
await writeFile(options.file, newDocument, 'utf-8');
289+
}
253290
const result: PushResult = {
291+
status: action,
254292
mapping,
255293
version: documentMetadata.version!,
256294
namespace,

shared/src/hub/canonical.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { canonicalEqual, canonicalize } from './canonical';
3+
4+
describe('canonical', () => {
5+
describe('canonicalEqual', () => {
6+
it('treats key-reordered objects as equal', () => {
7+
expect(canonicalEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true);
8+
});
9+
10+
it('treats nested key-reordered objects as equal', () => {
11+
expect(canonicalEqual(
12+
{ outer: { a: 1, b: { c: 3, d: 4 } } },
13+
{ outer: { b: { d: 4, c: 3 }, a: 1 } }
14+
)).toBe(true);
15+
});
16+
17+
it('detects value differences', () => {
18+
expect(canonicalEqual({ a: 1 }, { a: 2 })).toBe(false);
19+
});
20+
21+
it('is sensitive to array order', () => {
22+
expect(canonicalEqual({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
23+
});
24+
25+
it('compares primitives directly', () => {
26+
expect(canonicalEqual('x', 'x')).toBe(true);
27+
expect(canonicalEqual('x', 'y')).toBe(false);
28+
});
29+
});
30+
31+
describe('canonicalize', () => {
32+
it('sorts object keys recursively while preserving array order', () => {
33+
expect(JSON.stringify(canonicalize({ b: 2, a: [{ y: 1, x: 2 }] })))
34+
.toBe(JSON.stringify({ a: [{ x: 2, y: 1 }], b: 2 }));
35+
});
36+
});
37+
});

shared/src/hub/canonical.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Recursively sort object keys so two documents with the same content but different key ordering
3+
* compare equal. Array order is preserved.
4+
*/
5+
export function canonicalize(value: unknown): unknown {
6+
if (Array.isArray(value)) return value.map(canonicalize);
7+
if (value && typeof value === 'object') {
8+
const sorted: Record<string, unknown> = {};
9+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
10+
sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
11+
}
12+
return sorted;
13+
}
14+
return value;
15+
}
16+
17+
/**
18+
* Deep content equality that ignores object key ordering (array order is preserved). Used to decide
19+
* whether a local CALM document differs from the version CalmHub has already published.
20+
*/
21+
export function canonicalEqual(a: unknown, b: unknown): boolean {
22+
return JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b));
23+
}

shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export {
8787
type ControlDocumentKind
8888
} from './hub/document-id-utils.js';
8989
export { computeSemVerBump, compareSemVer, sortSemVer } from './hub/semver.js';
90+
export { canonicalEqual, canonicalize } from './hub/canonical.js';
9091
export {
9192
enrichWithDocumentPositions,
9293
parseDocumentWithPositions,

0 commit comments

Comments
 (0)