Skip to content

Commit 9a2805a

Browse files
committed
fix(cli): correct hub push --fail-if-modified comparison and extend to control docs
Addresses review feedback on the strict-push comparison: - Normalise the local document the same way Hub stores it (rewriting the $id to the latest version and applying default fields via updateDocumentMetadata) before comparing, so a version-only difference (e.g. another committer auto-bumped Hub) or an absent `description` no longer reports a false "has changed". - Guard against an empty/unreadable latest-version body: surface a clear "could not read" error instead of silently reporting "has changed". - Point users at --change-type in the modified-document error message. - Extend --fail-if-modified to `control-requirement` and `control-configuration` push (same normalise-and-compare model). - Add Commander argv wiring tests so the flag plumbing is covered. Claude-Session: https://claude.ai/code/session_01QiHAHdi4d4mCoy4wku5s2v
1 parent cd73143 commit 9a2805a

5 files changed

Lines changed: 240 additions & 10 deletions

File tree

cli/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +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).
62+
- `hub push` auto-bumps by default (creates a new version off the latest). The `--fail-if-modified` flag (supported on `architecture`, `pattern`, `standard`, `control-requirement` and `control-configuration`) 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. The local document is normalised the same way Hub stores it (via `updateDocumentMetadata`/`updateControlDocumentMetadata`) before comparing with `canonicalEqual` from `@finos/calm-shared`, so a version-only or defaulted-field difference is not mistaken for a content change.
6363

6464
### Important Directories
6565
```

cli/src/cli.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,31 @@ describe('CLI Commands', () => {
784784
}));
785785
});
786786

787+
it('passes --fail-if-modified through to the architecture handler', async () => {
788+
await program.parseAsync([
789+
'node', 'cli.js', 'hub', 'push', 'architecture',
790+
'arch.json',
791+
'--calm-hub-url', 'http://hub',
792+
'--fail-if-modified',
793+
]);
794+
795+
expect(hubCommandsModule.runPushArchitecture).toHaveBeenCalledWith(expect.objectContaining({
796+
failIfModified: true,
797+
}));
798+
});
799+
800+
it('defaults --fail-if-modified to false when not provided', async () => {
801+
await program.parseAsync([
802+
'node', 'cli.js', 'hub', 'push', 'architecture',
803+
'arch.json',
804+
'--calm-hub-url', 'http://hub',
805+
]);
806+
807+
expect(hubCommandsModule.runPushArchitecture).toHaveBeenCalledWith(expect.objectContaining({
808+
failIfModified: false,
809+
}));
810+
});
811+
787812
it('passes --format pretty through to the handler', async () => {
788813
await program.parseAsync([
789814
'node', 'cli.js', 'hub', 'push', 'architecture',
@@ -1087,6 +1112,18 @@ describe('CLI Commands', () => {
10871112
changeType: 'PATCH',
10881113
}));
10891114
});
1115+
1116+
it('passes --fail-if-modified through to the handler', async () => {
1117+
await program.parseAsync([
1118+
'node', 'cli.js', 'hub', 'push', 'control-requirement', 'req.json',
1119+
'--calm-hub-url', 'http://hub',
1120+
'--fail-if-modified',
1121+
]);
1122+
1123+
expect(hubCommandsModule.runPushControlRequirement).toHaveBeenCalledWith(expect.objectContaining({
1124+
failIfModified: true,
1125+
}));
1126+
});
10901127
});
10911128

10921129
describe('pull control-requirement command', () => {

cli/src/cli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,12 +543,14 @@ Example:
543543
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
544544
.addOption(hubOutputOption)
545545
.addOption(hubVersionBumpOption)
546+
.addOption(hubFailIfModifiedOption)
546547
.action(async (requirementFile, options) => {
547548
const pushOptions: PushControlOptions = {
548549
calmHubOptions: { calmHubUrl: options.calmHubUrl },
549550
file: requirementFile,
550551
format: options.format,
551-
changeType: options.changeType.toUpperCase() as ResourceChangeType
552+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
553+
failIfModified: options.failIfModified
552554
};
553555
await runPushControlRequirement(pushOptions);
554556
});
@@ -559,12 +561,14 @@ Example:
559561
.option(CALMHUB_URL_OPTION, 'URL to CALMHub instance')
560562
.addOption(hubOutputOption)
561563
.addOption(hubVersionBumpOption)
564+
.addOption(hubFailIfModifiedOption)
562565
.action(async (configFile, options) => {
563566
const pushOptions: PushControlOptions = {
564567
calmHubOptions: { calmHubUrl: options.calmHubUrl },
565568
file: configFile,
566569
format: options.format,
567-
changeType: options.changeType.toUpperCase() as ResourceChangeType
570+
changeType: options.changeType.toUpperCase() as ResourceChangeType,
571+
failIfModified: options.failIfModified
568572
};
569573
await runPushControlConfiguration(pushOptions);
570574
});

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

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,15 @@ describe('hub-commands', () => {
342342
it('skips (does not create a new version) when content is unchanged from the latest published version', async () => {
343343
const { mockClient } = await getSharedMocks();
344344
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')));
345+
// The stored document is the local doc as Hub normalises it on the way in: $id at the
346+
// latest version and a defaulted empty description. The local file (pushDoc) has no
347+
// description and is only compared after the same normalisation, so this must match.
348+
vi.mocked(mockClient.getMappedResourceByVersion).mockResolvedValue({
349+
$id: 'http://hub/calm/namespaces/finos/architectures/my-arch/versions/1.0.0',
350+
title: 'my-arch',
351+
description: '',
352+
nodes: []
353+
});
347354

348355
await runPushArchitecture({
349356
calmHubOptions: { calmHubUrl: 'http://hub' },
@@ -359,6 +366,48 @@ describe('hub-commands', () => {
359366
);
360367
});
361368

369+
it('does not flag a version-only difference (auto-bumped elsewhere) as modified', async () => {
370+
const { mockClient } = await getSharedMocks();
371+
// Hub has moved on to 1.1.0; the local file still declares 1.0.0 but the architecture
372+
// content is identical. Normalising to the latest version must make these compare equal.
373+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0', '1.1.0']);
374+
vi.mocked(mockClient.getMappedResourceByVersion).mockResolvedValue({
375+
$id: 'http://hub/calm/namespaces/finos/architectures/my-arch/versions/1.1.0',
376+
title: 'my-arch',
377+
description: '',
378+
nodes: []
379+
});
380+
381+
await runPushArchitecture({
382+
calmHubOptions: { calmHubUrl: 'http://hub' },
383+
file: 'arch.json',
384+
failIfModified: true
385+
});
386+
387+
expect(mockClient.getMappedResourceByVersion).toHaveBeenCalledWith('finos', 'my-arch', '1.1.0', 'architectures');
388+
expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled();
389+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
390+
expect.objectContaining({ status: 'skipped', version: '1.1.0' })
391+
);
392+
});
393+
394+
it('fails clearly when Hub returns an empty body for the latest version', async () => {
395+
const { mockClient } = await getSharedMocks();
396+
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
397+
vi.mocked(mockClient.getMappedResourceByVersion).mockResolvedValue(undefined as unknown as object);
398+
399+
await expect(runPushArchitecture({
400+
calmHubOptions: { calmHubUrl: 'http://hub' },
401+
file: 'arch.json',
402+
failIfModified: true
403+
})).rejects.toThrow('process.exit');
404+
405+
expect(mockClient.createMappedResourceVersion).not.toHaveBeenCalled();
406+
expect(hubOutput.printError).toHaveBeenCalledWith(
407+
0, expect.stringContaining('Could not read the latest published version'), expect.any(String), 'json'
408+
);
409+
});
410+
362411
it('fails when the document has changed relative to the latest published version', async () => {
363412
const { mockClient } = await getSharedMocks();
364413
vi.mocked(mockClient.getMappedResourceVersions).mockResolvedValue(['1.0.0']);
@@ -1186,6 +1235,52 @@ describe('hub-commands', () => {
11861235
.rejects.toThrow('process.exit');
11871236
expect(hubOutput.printError).toHaveBeenCalledWith(409, 'Version already exists', expect.any(String), 'json');
11881237
});
1238+
1239+
describe('--fail-if-modified', () => {
1240+
it('creates 1.0.0 for a brand-new requirement even with the flag set', async () => {
1241+
const { mockClient } = await getSharedMocks();
1242+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1243+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue([]);
1244+
vi.mocked(mockClient.createControlRequirementVersion).mockResolvedValue(reqId('1.0.0'));
1245+
1246+
await runPushControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', failIfModified: true });
1247+
1248+
expect(mockClient.getControlRequirementVersion).not.toHaveBeenCalled();
1249+
expect(mockClient.createControlRequirementVersion).toHaveBeenCalledWith('security', 'access-control', '1.0.0', expect.any(String));
1250+
});
1251+
1252+
it('skips when the requirement is unchanged from the latest published version', async () => {
1253+
const { mockClient } = await getSharedMocks();
1254+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1255+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']);
1256+
// Stored doc normalised to its latest version (only the $id version differs from disk).
1257+
vi.mocked(mockClient.getControlRequirementVersion).mockResolvedValue({ $id: reqId('1.0.0'), nodes: [] });
1258+
1259+
await runPushControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', failIfModified: true });
1260+
1261+
expect(mockClient.getControlRequirementVersion).toHaveBeenCalledWith('security', 'access-control', '1.0.0');
1262+
expect(mockClient.createControlRequirementVersion).not.toHaveBeenCalled();
1263+
expect(fs.writeFile).not.toHaveBeenCalled();
1264+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1265+
expect.objectContaining({ status: 'skipped', version: '1.0.0' })
1266+
);
1267+
});
1268+
1269+
it('fails when the requirement has changed relative to the latest published version', async () => {
1270+
const { mockClient } = await getSharedMocks();
1271+
vi.mocked(fs.readFile).mockResolvedValue(controlReqDoc() as unknown as Uint8Array);
1272+
vi.mocked(mockClient.getControlRequirementVersions).mockResolvedValue(['1.0.0']);
1273+
vi.mocked(mockClient.getControlRequirementVersion).mockResolvedValue({ $id: reqId('1.0.0'), nodes: [{ 'unique-id': 'changed' }] });
1274+
1275+
await expect(runPushControlRequirement({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'req.json', failIfModified: true }))
1276+
.rejects.toThrow('process.exit');
1277+
1278+
expect(mockClient.createControlRequirementVersion).not.toHaveBeenCalled();
1279+
expect(hubOutput.printError).toHaveBeenCalledWith(
1280+
0, expect.stringContaining('has changed relative to the latest published version'), expect.any(String), 'json'
1281+
);
1282+
});
1283+
});
11891284
});
11901285

11911286
// ── runPushControlConfiguration ────────────────────────────────────────────
@@ -1240,6 +1335,38 @@ describe('hub-commands', () => {
12401335
0, expect.stringContaining('describes a control requirement, but a control configuration was expected'), expect.any(String), 'json'
12411336
);
12421337
});
1338+
1339+
describe('--fail-if-modified', () => {
1340+
it('skips when the configuration is unchanged from the latest published version', async () => {
1341+
const { mockClient } = await getSharedMocks();
1342+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1343+
vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']);
1344+
vi.mocked(mockClient.getControlConfigurationVersion).mockResolvedValue({ $id: cfgId('1.0.0'), nodes: [] });
1345+
1346+
await runPushControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', failIfModified: true });
1347+
1348+
expect(mockClient.getControlConfigurationVersion).toHaveBeenCalledWith('security', 'access-control', 'prod', '1.0.0');
1349+
expect(mockClient.createControlConfigurationVersion).not.toHaveBeenCalled();
1350+
expect(hubOutput.printJsonSuccess).toHaveBeenCalledWith(
1351+
expect.objectContaining({ status: 'skipped', configName: 'prod', version: '1.0.0' })
1352+
);
1353+
});
1354+
1355+
it('fails when the configuration has changed relative to the latest published version', async () => {
1356+
const { mockClient } = await getSharedMocks();
1357+
vi.mocked(fs.readFile).mockResolvedValue(controlConfigDoc() as unknown as Uint8Array);
1358+
vi.mocked(mockClient.getControlConfigurationVersions).mockResolvedValue(['1.0.0']);
1359+
vi.mocked(mockClient.getControlConfigurationVersion).mockResolvedValue({ $id: cfgId('1.0.0'), nodes: [{ 'unique-id': 'changed' }] });
1360+
1361+
await expect(runPushControlConfiguration({ calmHubOptions: { calmHubUrl: 'http://hub' }, file: 'config.json', failIfModified: true }))
1362+
.rejects.toThrow('process.exit');
1363+
1364+
expect(mockClient.createControlConfigurationVersion).not.toHaveBeenCalled();
1365+
expect(hubOutput.printError).toHaveBeenCalledWith(
1366+
0, expect.stringContaining('has changed relative to the latest published version'), expect.any(String), 'json'
1367+
);
1368+
});
1369+
});
12431370
});
12441371

12451372
// ── runPullControlRequirement ──────────────────────────────────────────────

0 commit comments

Comments
 (0)