diff --git a/calm-models/src/types/index.spec.ts b/calm-models/src/types/index.spec.ts new file mode 100644 index 000000000..8a799cc5a --- /dev/null +++ b/calm-models/src/types/index.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST, isNarrativeDocumentType } from './index'; + +describe('isNarrativeDocumentType', () => { + it.each(CALM_NARRATIVE_DOCUMENT_TYPES_LIST)('accepts %s', (type) => { + expect(isNarrativeDocumentType(type)).toBe(true); + }); + + it('rejects unsupported values', () => { + expect(isNarrativeDocumentType('architecture')).toBe(false); + expect(isNarrativeDocumentType(1)).toBe(false); + }); +}); diff --git a/calm-models/src/types/index.ts b/calm-models/src/types/index.ts index 9881f1bbb..c3803e473 100644 --- a/calm-models/src/types/index.ts +++ b/calm-models/src/types/index.ts @@ -22,6 +22,14 @@ export const CALM_DOCUMENT_TYPES_LIST = [ export type CalmDocumentType = (typeof CALM_DOCUMENT_TYPES_LIST)[number]; +export const CALM_NARRATIVE_DOCUMENT_TYPES_LIST = ['knowledge', 'sad'] as const; + +export type NarrativeDocumentType = (typeof CALM_NARRATIVE_DOCUMENT_TYPES_LIST)[number]; + +export function isNarrativeDocumentType(input: unknown): input is NarrativeDocumentType { + return typeof input === 'string' && CALM_NARRATIVE_DOCUMENT_TYPES_LIST.includes(input as NarrativeDocumentType); +} + export function isValidCalmDocumentType(input: string): input is CalmDocumentType { return CALM_DOCUMENT_TYPES_LIST.some((type) => type === input); } diff --git a/cli/README.md b/cli/README.md index f09f02496..72c57d33b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -828,25 +828,31 @@ calm workspace init my-system #### `calm workspace add ` -Register a CALM document with the active workspace. By default the file is referenced at its current location on disk (no copying). Prompts interactively for document type and (manifest) name if they cannot be determined automatically. +Register a CALM JSON document or narrative Markdown document with the active workspace. By default the file is referenced at its current location on disk (no copying). Prompts interactively for document type and (manifest) name if they cannot be determined automatically. ``` -calm workspace add [--id ] [--type ] [--namespace ] [--copy] +calm workspace add [--id ] [--type ] [--namespace ] [--copy] [--calm-hub-document-id --ver [--calm-hub-url ]] ``` | Option | Description | |--------|-------------| | `--id ` | Explicit manifest registration id. Overrides automatic resolution. | -| `--type ` | Document type. If omitted, an interactive dropdown is shown. One of: `pattern`, `architecture`, `interface`, `flow`, `control`, `schema`, `timeline`, `adr`. | -| `--namespace ` | CalmHub namespace to record in the manifest. If omitted, it is derived from the document `$id`. | +| `--type ` | Document type. If omitted, an interactive dropdown is shown. One of: `pattern`, `architecture`, `interface`, `flow`, `control`, `schema`, `timeline`, `adr`, `knowledge`, `sad`. | +| `--namespace ` | CalmHub namespace to record in the manifest. It is required for narrative Markdown and otherwise derived from the document `$id` when omitted. | | `--copy` | Copy the file into the bundle's `files/` directory instead of referencing it in place. | +| `--calm-hub-document-id ` and `--ver ` | Recover an existing narrative document. Both options are required together. | +| `--calm-hub-url ` | Optional CalmHub URL used only for narrative recovery. It otherwise uses the configured URL. | -**Document `$id` handling.** `add` inspects the file's CalmHub `$id`: +**Narrative Markdown documents.** Use `--type knowledge` or `sad`. `add` reads YAML frontmatter. A non-empty `title` becomes the manifest name unless you supply `--id`. `--namespace` is required. The initial manifest version is `1.0.0`. Markdown has no CALM `$id` and is never rewritten. + +To restore a removed narrative document without creating a new CalmHub document, supply its verified Hub id and version. The local Markdown must exactly match the stored Hub version. + +**JSON document `$id` handling.** For JSON mapping documents, `add` inspects the file's CalmHub `$id`: - **No `$id`** → you are prompted interactively to build one from its components (see below); the `$id` is written into the file and the document is added. - **Conformant `$id`** → left untouched; the manifest namespace is derived from it. - **Non-conformant `$id`** → left as-is; a warning is printed and the document is still tracked, but it cannot be pushed to CalmHub until the `$id` is fixed (silently rewriting it would lose data for types that don't use CalmHub URLs, e.g. `flow`, `adr`, `timeline`). -**Manifest name resolution** (when `--id` is not given): the `title` field from the JSON file, else an interactive prompt. +**Manifest name resolution** (when `--id` is not given): the `title` field from the JSON file or Markdown frontmatter, else an interactive prompt. ```shell # Interactive — prompts for type, builds the $id if needed, then the manifest name @@ -854,6 +860,12 @@ calm workspace add ./architectures/payment-service.json # Reference an already-conformant document without copying calm workspace add ./architectures/payment-service.json --type architecture + +# Register a narrative Markdown document; the frontmatter title becomes its manifest name +calm workspace add ./docs/payments-sad.md --type sad --namespace finos + +# Restore an existing narrative document +calm workspace add ./docs/payments-sad.md --type sad --namespace finos --calm-hub-document-id 42 --ver 1.2.0 ``` #### `calm workspace new [type] [name] [template]` @@ -888,7 +900,9 @@ where `$TYPE` is one of `patterns`, `architectures`, `standards`, `interfaces`. #### `calm workspace push` -Push every document in the workspace manifest to a CalmHub instance. Each document's identity — namespace, type, mapping id and **version** — comes from its `$id` (of the form `$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION`). Push **does not auto-bump**: it creates exactly the version each document declares. Documents without a well-formed mapping `$id` (or whose type has no CalmHub resource type) are skipped with a warning. +Push every document in the workspace manifest to a CalmHub instance. JSON mapping documents derive their identity — namespace, type, mapping id and **version** — from `$id` (of the form `$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION`). Push **does not auto-bump**: it creates exactly the version each document declares. Documents without a well-formed mapping `$id` (or whose type has no CalmHub resource type) are skipped with a warning. + +Narrative Markdown documents use `--type knowledge` or `--type sad`. They require YAML frontmatter with a `title` and `--namespace`. The first push stores the Hub numeric document id, location, and version (`1.0.0`) in `workspace-manifest.json`. Later changes require `workspace bump`; the command updates the manifest version without rewriting the Markdown. ``` calm workspace push [--calm-hub-url ] [--fail-if-modified] @@ -912,6 +926,17 @@ calm workspace push --calm-hub-url https://calmhub.example.com calm workspace push --fail-if-modified # strict merge-time mode ``` +```shell +# First-class document POC: add, publish, inspect, edit, bump, and publish again +calm workspace add ./docs/payments-sad.md --type sad --namespace finos +calm workspace push --calm-hub-url http://localhost:8080 +calm workspace show # shows the published Hub location +calm workspace check --calm-hub-url http://localhost:8080 +# Edit ./docs/payments-sad.md, then bump and publish the new version +calm workspace bump --minor --calm-hub-url http://localhost:8080 +calm workspace push --calm-hub-url http://localhost:8080 +``` + #### `calm workspace check` Check whether any tracked document has changed on disk relative to CalmHub but has **not** been version-bumped. Intended as a CI/PR gate — it **exits non-zero** when a bump is required, so a PR cannot merge with unversioned changes. diff --git a/cli/smoke/harness/hub-api.ts b/cli/smoke/harness/hub-api.ts index 323874a01..a1e7a5d5d 100644 --- a/cli/smoke/harness/hub-api.ts +++ b/cli/smoke/harness/hub-api.ts @@ -39,5 +39,10 @@ export function hubApi(baseUrl: string = SMOKE_HUB_URL) { `${baseUrl}/calm/namespaces/${namespace}/${type}/${mapping}/versions/${version}` ); }, + async getNarrativeDocument(namespace: string, type: string, id: number, version: string): Promise { + const body = await getJson(`${baseUrl}/api/calm/namespaces/${namespace}/documents/${type}/${id}/versions/${version}`); + if (typeof body.documentMarkdown !== 'string') throw new Error('Narrative document response has no documentMarkdown'); + return body.documentMarkdown; + }, }; } diff --git a/cli/smoke/workspace-documents.smoke.spec.ts b/cli/smoke/workspace-documents.smoke.spec.ts new file mode 100644 index 000000000..a76d53f19 --- /dev/null +++ b/cli/smoke/workspace-documents.smoke.spec.ts @@ -0,0 +1,72 @@ +import path from 'path'; +import * as fs from 'fs'; +import { execSync } from 'child_process'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { installPackedCli, type CliInstall } from '../src/test_helpers/cli-runner'; +import { SMOKE_HUB_URL } from './global-setup'; +import { hubApi } from './harness/hub-api'; +import { hubDocId } from './harness/fixtures'; + +const CLI_ROOT = path.resolve(__dirname, '..'); +const NS = 'smoke-workspace-documents'; +const api = hubApi(); + +describe('workspace narrative-document POC', () => { + let cli: CliInstall; + let wsDir: string; + let documentPath: string; + let architecturePath: string; + let documentId: number; + const initial = '---\ntitle: Payments SAD\ndescription: Smoke document\n---\n# Payments\n'; + + async function run(args: string[]) { + return cli.run(args, { cwd: wsDir }); + } + + beforeAll(async () => { + cli = installPackedCli(CLI_ROOT, 'calm-smoke-workspace-documents'); + wsDir = path.join(cli.tempDir, 'repo'); + fs.mkdirSync(wsDir, { recursive: true }); + execSync('git init', { cwd: wsDir, stdio: 'inherit' }); + documentPath = path.join(wsDir, 'payments-sad.md'); + fs.writeFileSync(documentPath, initial); + architecturePath = path.join(wsDir, 'payments.architecture.json'); + fs.writeFileSync(architecturePath, JSON.stringify({ + $schema: 'https://calm.finos.org/release/1.0/meta/calm.json', + $id: hubDocId(NS, 'architectures', 'payments', '1.0.0'), + title: 'Payments', nodes: [], relationships: [], + }, null, 2)); + await cli.run(['hub', 'create', 'namespace', '--name', NS, '--description', 'workspace documents smoke', '-c', SMOKE_HUB_URL]); + }, 120_000); + + afterAll(() => cli?.cleanup()); + + test('publishes, retrieves, bumps, and republishes a Markdown document', async () => { + await run(['workspace', 'init', 'documents']); + await run(['workspace', 'add', architecturePath, '--type', 'architecture', '--namespace', NS]); + await run(['workspace', 'add', documentPath, '--type', 'sad', '--namespace', NS]); + await run(['workspace', 'push', '--calm-hub-url', SMOKE_HUB_URL]); + expect(await api.listVersions(NS, 'architectures', 'payments')).toContain('1.0.0'); + + const manifestPath = path.join(wsDir, '.calm-workspace', 'bundles', 'documents', 'workspace-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + documentId = manifest['Payments SAD'].calmHubDocumentId; + expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.0.0')).toBe(initial); + + fs.writeFileSync(documentPath, initial.replace('# Payments', '# Updated payments')); + await expect(run(['workspace', 'check', '--calm-hub-url', SMOKE_HUB_URL])).rejects.toHaveProperty('exitCode', 1); + await run(['workspace', 'bump', '--minor', '--calm-hub-url', SMOKE_HUB_URL]); + await run(['workspace', 'push', '--calm-hub-url', SMOKE_HUB_URL]); + expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.1.0')).toContain('# Updated payments'); + + await run(['workspace', 'rm', 'Payments SAD']); + await run([ + 'workspace', 'add', documentPath, '--type', 'sad', '--namespace', NS, + '--calm-hub-document-id', String(documentId), '--ver', '1.1.0', '--calm-hub-url', SMOKE_HUB_URL, + ]); + const recovered = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + expect(recovered['Payments SAD']).toMatchObject({ calmHubDocumentId: documentId, version: '1.1.0' }); + await run(['workspace', 'push', '--fail-if-modified', '--calm-hub-url', SMOKE_HUB_URL]); + expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.0.0')).toBe(initial); + }); +}); diff --git a/cli/src/command-helpers/workspace/bump.spec.ts b/cli/src/command-helpers/workspace/bump.spec.ts index 7f69aa571..69cec9e53 100644 --- a/cli/src/command-helpers/workspace/bump.spec.ts +++ b/cli/src/command-helpers/workspace/bump.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { detectChangedResources, bumpWorkspace, canonicalEqual, maxIncrement } from './bump'; -import { saveManifest } from './bundle'; +import { loadManifest, saveManifest } from './bundle'; import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; @@ -10,6 +10,15 @@ vi.mock('@finos/calm-shared', async (importOriginal) => ({ initLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); +vi.mock('./bundle', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadManifest: vi.fn(actual.loadManifest), + saveManifest: vi.fn(actual.saveManifest), + }; +}); + const BASE = 'https://hub.example.com'; const idAt = (resource: string, version: string, type = 'architectures', ns = 'com.example') => `${BASE}/calm/namespaces/${ns}/${type}/${resource}/versions/${version}`; @@ -17,12 +26,26 @@ const idAt = (resource: string, version: string, type = 'architectures', ns = 'c interface ClientOpts { versions?: Record; remote?: Record; + narrativeVersions?: string[]; + narrativeMarkdown?: string; } const makeClient = (opts: ClientOpts = {}): CalmHubClient => ({ getMappedResourceVersions: vi.fn(async (_ns: string, mappingId: string) => opts.versions?.[mappingId] ?? []), getMappedResourceByVersion: vi.fn(async (_ns: string, mappingId: string, version: string) => opts.remote?.[`${mappingId}@${version}`] ?? {}), + getNarrativeDocumentVersions: vi.fn(async () => opts.narrativeVersions ?? []), + getNarrativeDocumentVersion: vi.fn(async () => ({ documentMarkdown: opts.narrativeMarkdown ?? '' })), }) as unknown as CalmHubClient; +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +}; + describe('bump', () => { const bundlePath = path.join(__dirname, 'test-bump', 'bundle'); const filesPath = path.join(bundlePath, 'files'); @@ -49,6 +72,318 @@ describe('bump', () => { }); describe('detectChangedResources', () => { + it('treats new, already-bumped, and unchanged narrative documents as clean', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Published\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + const baseEntry = { + path: 'files/payments.md', type: 'sad' as const, namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }; + + await saveManifest(bundlePath, { payments: { ...baseEntry, calmHubDocumentId: undefined, calmHubId: undefined } }); + expect(await detectChangedResources(bundlePath, makeClient())).toEqual([]); + + await saveManifest(bundlePath, { payments: { ...baseEntry, version: '1.1.0' } }); + expect(await detectChangedResources(bundlePath, makeClient({ narrativeVersions: ['1.0.0'] }))).toEqual([]); + + await saveManifest(bundlePath, { payments: baseEntry }); + expect(await detectChangedResources(bundlePath, makeClient({ narrativeVersions: ['1.0.0'], narrativeMarkdown: markdown }))).toEqual([]); + }); + + it('treats pending create recovery as unassigned without mutating the fence', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Published\n'; + const entry = { + path: 'files/payments.md', type: 'sad' as const, namespace: 'com.example', version: '1.0.0', + createRecovery: { pending: true as const }, + }; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { payments: entry }); + const client = makeClient(); + + expect(await detectChangedResources(bundlePath, client)).toEqual([]); + + expect(client.getNarrativeDocumentVersions).not.toHaveBeenCalled(); + expect(await loadManifest(bundlePath)).toEqual({ payments: entry }); + }); + + it('fails narrative checks with incomplete identity or missing source', async () => { + await saveManifest(bundlePath, { + partial: { path: 'files/missing.md', type: 'sad', namespace: 'com.example', version: '1.0.0', calmHubId: '/partial' }, + }); + await expect(detectChangedResources(bundlePath, makeClient())).rejects.toThrow(/file not found/); + + await writeFile(path.join(filesPath, 'partial.md'), '---\ntitle: Partial\n---\n# Partial'); + await saveManifest(bundlePath, { + partial: { path: 'files/partial.md', type: 'sad', namespace: 'com.example', version: '1.0.0', calmHubId: '/partial' }, + }); + await expect(detectChangedResources(bundlePath, makeClient())).rejects.toThrow(/incomplete Hub identity/); + }); + + it('rejects a missing namespace before Hub calls and accepts a valid namespace', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + const entry = { + path: 'files/payments.md', type: 'sad' as const, version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }; + const invalidClient = makeClient(); + await saveManifest(bundlePath, { payments: entry }); + await expect(detectChangedResources(bundlePath, invalidClient)).rejects.toThrow(/valid namespace/); + expect(invalidClient.getNarrativeDocumentVersions).not.toHaveBeenCalled(); + + const validClient = makeClient({ narrativeVersions: [] }); + await saveManifest(bundlePath, { payments: { ...entry, namespace: 'com.example' } }); + await expect(detectChangedResources(bundlePath, validClient)).resolves.toEqual([]); + expect(validClient.getNarrativeDocumentVersions).toHaveBeenCalledWith('com.example', 'sad', 42); + }); + + it('preserves files and manifest when a later narrative entry fails the scan', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Changed\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + missing: { path: 'files/missing.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + const before = await readFile(path.join(bundlePath, 'workspace-manifest.json'), 'utf8'); + const client = makeClient({ narrativeVersions: ['1.0.0'], narrativeMarkdown: markdown.replace('Changed', 'Published') }); + + await expect(bumpWorkspace(bundlePath, client, { increment: 'MINOR' })).rejects.toThrow(/file not found/); + + expect(client.getNarrativeDocumentVersion).toHaveBeenCalled(); + expect(await readFile(path.join(bundlePath, 'workspace-manifest.json'), 'utf8')).toBe(before); + expect(await readFile(path.join(filesPath, 'payments.md'), 'utf8')).toBe(markdown); + }); + + it('fails narrative checks when Hub version retrieval fails', async () => { + await writeFile(path.join(filesPath, 'payments.md'), '---\ntitle: Payments SAD\n---\n# Payments'); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ narrativeVersions: ['1.0.0'] }); + (client.getNarrativeDocumentVersion as ReturnType).mockRejectedValueOnce(new Error('Hub unavailable')); + + await expect(detectChangedResources(bundlePath, client)).rejects.toThrow(/Hub unavailable/); + }); + + it('checks narrative documents concurrently and returns changes in manifest order', async () => { + await writeFile(path.join(filesPath, 'first.md'), '---\ntitle: First\n---\n# First changed'); + await writeFile(path.join(filesPath, 'second.md'), '---\ntitle: Second\n---\n# Second changed'); + await saveManifest(bundlePath, { + first: { + path: 'files/first.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 41, calmHubId: '/api/calm/namespaces/com.example/documents/sad/41/versions/1.0.0', + }, + second: { + path: 'files/second.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const firstEntered = deferred(); + const secondEntered = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + const secondCompleted = deferred(); + const client = makeClient(); + vi.mocked(client.getNarrativeDocumentVersions).mockImplementation(async (_namespace, _type, documentId) => { + if (documentId === 41) { + firstEntered.resolve(); + await releaseFirst.promise; + } else { + secondEntered.resolve(); + await releaseSecond.promise; + } + return ['1.0.0']; + }); + vi.mocked(client.getNarrativeDocumentVersion).mockImplementation(async (_namespace, _type, documentId) => { + if (documentId === 42) secondCompleted.resolve(); + return { documentMarkdown: '# Published' }; + }); + + const detection = detectChangedResources(bundlePath, client); + await Promise.all([firstEntered.promise, secondEntered.promise]); + releaseSecond.resolve(); + await secondCompleted.promise; + releaseFirst.resolve(); + + await expect(detection).resolves.toMatchObject([{ id: 'first' }, { id: 'second' }]); + }); + + it('checks mapping documents concurrently', async () => { + await write('first.json', { $id: idAt('first', '1.0.0'), title: 'First changed' }); + await write('second.json', { $id: idAt('second', '1.0.0'), title: 'Second changed' }); + await saveManifest(bundlePath, { + first: { path: 'files/first.json', type: 'architecture' }, + second: { path: 'files/second.json', type: 'architecture' }, + }); + const firstEntered = deferred(); + const secondEntered = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + const secondCompleted = deferred(); + const client = makeClient(); + vi.mocked(client.getMappedResourceVersions).mockImplementation(async (_namespace, mappingId) => { + if (mappingId === 'first') { + firstEntered.resolve(); + await releaseFirst.promise; + } else { + secondEntered.resolve(); + await releaseSecond.promise; + } + return ['1.0.0']; + }); + vi.mocked(client.getMappedResourceByVersion).mockImplementation(async (_namespace, mappingId) => { + if (mappingId === 'second') secondCompleted.resolve(); + return { $id: idAt(mappingId, '1.0.0'), title: 'Published' }; + }); + + const detection = detectChangedResources(bundlePath, client); + await Promise.all([firstEntered.promise, secondEntered.promise]); + releaseSecond.resolve(); + await secondCompleted.promise; + releaseFirst.resolve(); + + await expect(detection).resolves.toMatchObject([{ id: 'first' }, { id: 'second' }]); + }); + + it('overlaps mapping and narrative Hub checks', async () => { + await write('architecture.json', { $id: idAt('architecture', '1.0.0'), title: 'Changed' }); + await writeFile(path.join(filesPath, 'sad.md'), '---\ntitle: SAD\n---\n# Changed'); + await saveManifest(bundlePath, { + architecture: { path: 'files/architecture.json', type: 'architecture' }, + sad: { + path: 'files/sad.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const mappingEntered = deferred(); + const narrativeEntered = deferred(); + const releaseMapping = deferred(); + const releaseNarrative = deferred(); + const client = makeClient(); + vi.mocked(client.getMappedResourceVersions).mockImplementation(async () => { + mappingEntered.resolve(); + await releaseMapping.promise; + return ['1.0.0']; + }); + vi.mocked(client.getMappedResourceByVersion).mockResolvedValue({ + $id: idAt('architecture', '1.0.0'), title: 'Published', + }); + vi.mocked(client.getNarrativeDocumentVersions).mockImplementation(async () => { + narrativeEntered.resolve(); + await releaseNarrative.promise; + return ['1.0.0']; + }); + vi.mocked(client.getNarrativeDocumentVersion).mockResolvedValue({ documentMarkdown: '# Published' }); + + const detection = detectChangedResources(bundlePath, client); + await Promise.all([mappingEntered.promise, narrativeEntered.promise]); + releaseNarrative.resolve(); + releaseMapping.resolve(); + + await expect(detection).resolves.toMatchObject([{ id: 'architecture' }, { id: 'sad' }]); + }); + + it('reports the first narrative Hub failure in manifest order', async () => { + await writeFile(path.join(filesPath, 'first.md'), '---\ntitle: First\n---\n# First'); + await writeFile(path.join(filesPath, 'second.md'), '---\ntitle: Second\n---\n# Second'); + await saveManifest(bundlePath, { + first: { + path: 'files/first.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 41, calmHubId: '/api/calm/namespaces/com.example/documents/sad/41/versions/1.0.0', + }, + second: { + path: 'files/second.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const firstEntered = deferred(); + const secondEntered = deferred(); + const failFirst = deferred(); + const failSecond = deferred(); + const client = makeClient(); + vi.mocked(client.getNarrativeDocumentVersions).mockImplementation(async (_namespace, _type, documentId) => { + if (documentId === 41) { + firstEntered.resolve(); + return failFirst.promise; + } + secondEntered.resolve(); + return failSecond.promise; + }); + + const detection = detectChangedResources(bundlePath, client); + await Promise.all([firstEntered.promise, secondEntered.promise]); + failSecond.reject(new Error('second failure')); + failFirst.reject(new Error('first failure')); + + await expect(detection).rejects.toThrow('first failure'); + }); + + it('treats a document with no Hub versions as new and rejects missing manifest versions', async () => { + await writeFile(path.join(filesPath, 'payments.md'), '---\ntitle: Payments SAD\n---\n# Payments'); + const entry = { + path: 'files/payments.md', type: 'sad' as const, namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }; + await saveManifest(bundlePath, { payments: entry }); + expect(await detectChangedResources(bundlePath, makeClient({ narrativeVersions: [] }))).toEqual([]); + + await saveManifest(bundlePath, { payments: { ...entry, version: undefined } }); + await expect(detectChangedResources(bundlePath, makeClient())).rejects.toThrow(/no manifest version/); + }); + + it('fails narrative checks when a tracked path cannot be read', async () => { + await saveManifest(bundlePath, { + unreadable: { path: 'files', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + await expect(detectChangedResources(bundlePath, makeClient())).rejects.toThrow(/could not be read/); + }); + + it('detects and bumps changed narrative Markdown without rewriting it', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Changed\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', + version: '1.0.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ narrativeVersions: ['1.0.0'], narrativeMarkdown: markdown.replace('Changed', 'Published') }); + + const changed = await detectChangedResources(bundlePath, client); + expect(changed).toHaveLength(1); + await bumpWorkspace(bundlePath, client, { increment: 'MINOR', preDetectedChanges: changed }); + + expect((await loadManifest(bundlePath)).payments.version).toBe('1.1.0'); + expect(await readFile(path.join(filesPath, 'payments.md'), 'utf8')).toBe(markdown); + }); + + it.each([ + ['MAJOR', '2.0.0'], + ['PATCH', '1.0.1'], + ] as const)('applies a %s bump to a changed narrative document', async (increment, version) => { + const markdown = '---\ntitle: Payments SAD\n---\n# Changed\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ narrativeVersions: ['1.0.0'], narrativeMarkdown: markdown.replace('Changed', 'Published') }); + + await bumpWorkspace(bundlePath, client, { increment }); + expect((await loadManifest(bundlePath)).payments.version).toBe(version); + + expect(await bumpWorkspace(bundlePath, client, { increment })).toMatchObject({ bumped: [] }); + }); + it('skips a brand-new resource with no versions in CalmHub', async () => { await write('a.json', { $id: idAt('a', '1.0.0'), title: 'A' }); await saveManifest(bundlePath, { 'a': { path: 'files/a.json', type: 'architecture' } }); @@ -122,6 +457,125 @@ describe('bump', () => { }); describe('bumpWorkspace', () => { + it('loads and saves the manifest once for a batch of narrative updates', async () => { + const entry = (name: string, documentId: number) => ({ + path: `files/${name}.md`, type: 'sad' as const, namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: documentId, + calmHubId: `/api/calm/namespaces/com.example/documents/sad/${documentId}/versions/1.0.0`, + }); + await writeFile(path.join(filesPath, 'payments.md'), '# Payments'); + await writeFile(path.join(filesPath, 'orders.md'), '# Orders'); + await saveManifest(bundlePath, { payments: entry('payments', 42), orders: entry('orders', 43) }); + vi.mocked(loadManifest).mockClear(); + vi.mocked(saveManifest).mockClear(); + + const result = await bumpWorkspace(bundlePath, makeClient(), { + increment: 'MINOR', + preDetectedChanges: [ + { id: 'payments', kind: 'narrative', filePath: path.join(filesPath, 'payments.md'), currentVersion: '1.0.0', latestHubVersion: '1.0.0' }, + { id: 'orders', kind: 'narrative', filePath: path.join(filesPath, 'orders.md'), currentVersion: '1.0.0', latestHubVersion: '1.0.0' }, + ], + }); + + expect(result.bumped.map(({ id, toVersion }) => ({ id, toVersion }))).toEqual([ + { id: 'payments', toVersion: '1.1.0' }, + { id: 'orders', toVersion: '1.1.0' }, + ]); + expect(saveManifest).toHaveBeenCalledTimes(1); + expect(saveManifest).toHaveBeenCalledWith(bundlePath, expect.objectContaining({ + payments: expect.objectContaining({ version: '1.1.0' }), + orders: expect.objectContaining({ version: '1.1.0' }), + })); + const saveCallOrder = vi.mocked(saveManifest).mock.invocationCallOrder[0]; + const updatePhaseLoads = vi.mocked(loadManifest).mock.invocationCallOrder.filter(order => order < saveCallOrder); + expect(updatePhaseLoads).toHaveLength(1); + }); + + it('does not save a partially validated narrative batch', async () => { + await writeFile(path.join(filesPath, 'payments.md'), '# Payments'); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + architecture: { path: 'files/a.json', type: 'architecture' }, + }); + vi.mocked(loadManifest).mockClear(); + vi.mocked(saveManifest).mockClear(); + + await expect(bumpWorkspace(bundlePath, makeClient(), { + increment: 'MINOR', + preDetectedChanges: [ + { id: 'payments', kind: 'narrative', filePath: path.join(filesPath, 'payments.md'), currentVersion: '1.0.0', latestHubVersion: '1.0.0' }, + { id: 'architecture', kind: 'narrative', filePath: path.join(filesPath, 'a.json'), currentVersion: '1.0.0', latestHubVersion: '1.0.0' }, + ], + })).rejects.toThrow(/no longer a narrative manifest entry/); + + expect(loadManifest).toHaveBeenCalledTimes(1); + expect(saveManifest).not.toHaveBeenCalled(); + expect((await loadManifest(bundlePath)).payments.version).toBe('1.0.0'); + }); + + it('does not save the manifest update phase for mapping-only changes', async () => { + await write('a.json', { $id: idAt('a', '1.0.0'), title: 'A', extra: 'edited' }); + await saveManifest(bundlePath, { a: { path: 'files/a.json', type: 'architecture' } }); + vi.mocked(saveManifest).mockClear(); + + const result = await bumpWorkspace(bundlePath, makeClient(), { + increment: 'MINOR', + preDetectedChanges: [{ + id: 'a', kind: 'mapping', filePath: path.join(filesPath, 'a.json'), + currentVersion: '1.0.0', latestHubVersion: '1.0.0', + metadata: { + rawDocumentId: idAt('a', '1.0.0'), baseUrl: BASE, name: 'A', + namespace: 'com.example', type: 'architectures', mapping: 'a', version: '1.0.0', + }, + }], + }); + + expect(saveManifest).not.toHaveBeenCalled(); + expect(result.bumped).toEqual([ + expect.objectContaining({ id: 'a', fromVersion: '1.0.0', toVersion: '1.1.0' }), + ]); + expect((await read('a.json')).$id).toBe(idAt('a', '1.1.0')); + }); + + it('preserves result order and updates both document kinds in a mixed batch', async () => { + await write('a.json', { $id: idAt('a', '1.0.0'), title: 'A', extra: 'edited' }); + await writeFile(path.join(filesPath, 'payments.md'), '# Payments'); + await saveManifest(bundlePath, { + a: { path: 'files/a.json', type: 'architecture' }, + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + + const result = await bumpWorkspace(bundlePath, makeClient(), { + increment: 'MINOR', + preDetectedChanges: [ + { + id: 'a', kind: 'mapping', filePath: path.join(filesPath, 'a.json'), + currentVersion: '1.0.0', latestHubVersion: '1.0.0', + metadata: { + rawDocumentId: idAt('a', '1.0.0'), baseUrl: BASE, name: 'A', + namespace: 'com.example', type: 'architectures', mapping: 'a', version: '1.0.0', + }, + }, + { id: 'payments', kind: 'narrative', filePath: path.join(filesPath, 'payments.md'), currentVersion: '1.0.0', latestHubVersion: '1.0.0' }, + ], + }); + + expect(result.bumped.map(({ id, toVersion }) => ({ id, toVersion }))).toEqual([ + { id: 'a', toVersion: '1.1.0' }, + { id: 'payments', toVersion: '1.1.0' }, + ]); + expect((await read('a.json')).$id).toBe(idAt('a', '1.1.0')); + expect((await loadManifest(bundlePath)).payments.version).toBe('1.1.0'); + }); + it('bumps a changed doc by one MINOR increment relative to the latest hub version', async () => { await write('a.json', { $id: idAt('a', '1.0.0'), title: 'A', extra: 'edited' }); await saveManifest(bundlePath, { 'a': { path: 'files/a.json', type: 'architecture' } }); diff --git a/cli/src/command-helpers/workspace/bump.ts b/cli/src/command-helpers/workspace/bump.ts index 67bf71dbd..f28378921 100644 --- a/cli/src/command-helpers/workspace/bump.ts +++ b/cli/src/command-helpers/workspace/bump.ts @@ -1,6 +1,12 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; -import { loadManifest, resolveFilePath } from './bundle'; +import { + loadManifest, + resolveFilePath, + saveManifest, + type MappingWorkspaceManifestEntry, + type NarrativeWorkspaceManifestEntry, +} from './bundle'; import { buildRefRulesFromDiskIds, syncReferences, RefUpdateResult } from './ref-rewrite'; import { CalmHubClient, @@ -14,6 +20,12 @@ import { initLogger, Logger, } from '@finos/calm-shared'; +import { resolveNarrativeEntry, validateNarrativeDocumentLocation } from './narrative-document'; +import { + dispatchWorkspaceManifestEntry, + resolveWorkspaceManifestEntry, + type WorkspaceManifestEntryOperations, +} from './document-kind'; // Re-exported for existing consumers (push.ts, tests) that import it from here. export { canonicalEqual }; @@ -40,14 +52,17 @@ function bumpDocumentContent(raw: string, metadata: DocumentMetadata): string { return JSON.stringify(json, null, 2); } -export interface ChangedResource { +interface ChangedResourceBase { id: string; filePath: string; - metadata: DocumentMetadata; currentVersion: string; latestHubVersion: string; } +export type ChangedResource = + | (ChangedResourceBase & { kind: 'mapping'; metadata: DocumentMetadata }) + | (ChangedResourceBase & { kind: 'narrative' }); + export interface BumpResult { bumped: Array<{ id: string; filePath: string; fromVersion: string; toVersion: string; triggeredBy?: string; increment?: ResourceChangeType }>; refUpdates: RefUpdateResult[]; @@ -65,6 +80,20 @@ export interface BumpOptions { getCascadeIncrement?: (docId: string, triggeredBy: string, defaultIncrement: ResourceChangeType) => Promise; } +interface DetectChangedEntryContext { + client: CalmHubClient; + filePath: string; + id: string; + raw: string; +} + +type DetectChangedEntryCheck = () => Promise; + +const DETECT_CHANGED_ENTRY_OPERATIONS = { + mapping: prepareChangedMappingEntry, + narrative: prepareChangedNarrativeEntry, +} satisfies WorkspaceManifestEntryOperations; + /** Returns the highest-priority increment from a list (MAJOR > MINOR > PATCH). */ export function maxIncrement(increments: ResourceChangeType[]): ResourceChangeType { if (increments.includes('MAJOR')) return 'MAJOR'; @@ -88,66 +117,124 @@ export async function detectChangedResources( client: CalmHubClient ): Promise { const manifest = await loadManifest(bundlePath); - const changed: ChangedResource[] = []; + const checks: DetectChangedEntryCheck[] = []; + let preparationFailure: { reason: unknown } | undefined; for (const [id, entry] of Object.entries(manifest)) { - const filePath = resolveFilePath(bundlePath, entry.path); - if (!existsSync(filePath)) { - logger.warn(`File not found for id '${id}': ${filePath}`); - continue; - } - - let raw: string; try { - raw = await readFile(filePath, 'utf8'); - } catch (e) { - logger.warn(`Failed to read file for id '${id}': ${e instanceof Error ? e.message : String(e)}`); - continue; - } + const document = resolveWorkspaceManifestEntry(entry); + const filePath = resolveFilePath(bundlePath, entry.path); + if (!existsSync(filePath)) { + if (document.handler.unreadableFile === 'fail') throw new Error(`Narrative document '${id}' file not found: ${filePath}`); + logger.warn(`File not found for id '${id}': ${filePath}`); + continue; + } - let metadata: DocumentMetadata; - try { - metadata = extractDocumentMetadata(raw); - } catch (e) { - logger.warn(`Skipping '${id}': not mappable to CalmHub (${e instanceof Error ? e.message : String(e)})`); - continue; - } - if (!metadata.namespace) { - logger.warn(`Skipping '${id}': document $id has no namespace.`); - continue; + let raw: string; + try { + raw = await readFile(filePath, 'utf8'); + } catch (e) { + if (document.handler.unreadableFile === 'fail') throw new Error(`Narrative document '${id}' could not be read: ${e instanceof Error ? e.message : String(e)}`); + logger.warn(`Failed to read file for id '${id}': ${e instanceof Error ? e.message : String(e)}`); + continue; + } + + const check = dispatchWorkspaceManifestEntry( + document, + DETECT_CHANGED_ENTRY_OPERATIONS, + { client, filePath, id, raw } + ); + if (check) checks.push(check); + } catch (reason) { + preparationFailure = { reason }; + break; } + } + + // Run only entries before the first local failure, then inspect results in manifest order. + const results = await Promise.allSettled(checks.map(check => check())); + const changed: ChangedResource[] = []; + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + if (result.value) changed.push(result.value); + } + if (preparationFailure) throw preparationFailure.reason; + + return changed; +} + +function prepareChangedNarrativeEntry( + entry: NarrativeWorkspaceManifestEntry, + context: DetectChangedEntryContext +): DetectChangedEntryCheck | undefined { + const { client, filePath, id, raw } = context; + // Bump stops on invalid narrative state because it writes local manifest versions; push can report independent failures together. + const { version, identity, hubIdentityAssigned } = resolveNarrativeEntry(id, entry, raw); + if (!hubIdentityAssigned) return undefined; + validateNarrativeDocumentLocation(entry.calmHubId, identity, false); + return async () => { + const versions = await client.getNarrativeDocumentVersions(identity.namespace, identity.type, identity.calmHubDocumentId); + if (versions.length === 0 || !versions.includes(version)) return undefined; + const remote = await client.getNarrativeDocumentVersion( + identity.namespace, identity.type, identity.calmHubDocumentId, version + ); + if (remote.documentMarkdown === raw) return undefined; + return { + id, filePath, currentVersion: version, + latestHubVersion: sortSemVer(versions)[versions.length - 1], kind: 'narrative', + }; + }; +} +function prepareChangedMappingEntry( + _entry: MappingWorkspaceManifestEntry, + context: DetectChangedEntryContext +): DetectChangedEntryCheck | undefined { + const { client, filePath, id, raw } = context; + let metadata: DocumentMetadata; + try { + metadata = extractDocumentMetadata(raw); + } catch (e) { + logger.warn(`Skipping '${id}': not mappable to CalmHub (${e instanceof Error ? e.message : String(e)})`); + return undefined; + } + const namespace = metadata.namespace; + if (!namespace) { + logger.warn(`Skipping '${id}': document $id has no namespace.`); + return undefined; + } + + return async () => { let versions: string[]; try { - versions = await client.getMappedResourceVersions(metadata.namespace, metadata.mapping, metadata.type); + versions = await client.getMappedResourceVersions(namespace, metadata.mapping, metadata.type); } catch (e) { logger.error(`Failed to fetch versions for '${id}': ${e instanceof Error ? e.message : String(e)}`); - continue; + return undefined; } - if (versions.length === 0) continue; // new resource — nothing to bump - if (!versions.includes(metadata.version)) continue; // already ahead — already bumped + if (versions.length === 0) return undefined; // new resource — nothing to bump + if (!versions.includes(metadata.version)) return undefined; // already ahead — already bumped let remote: object; try { - remote = await client.getMappedResourceByVersion(metadata.namespace, metadata.mapping, metadata.version, metadata.type); + remote = await client.getMappedResourceByVersion(namespace, metadata.mapping, metadata.version, metadata.type); } catch (e) { logger.error(`Failed to fetch '${id}' @ ${metadata.version} from CalmHub: ${e instanceof Error ? e.message : String(e)}`); - continue; + return undefined; } - if (canonicalEqual(JSON.parse(raw), remote)) continue; // unchanged + if (canonicalEqual(JSON.parse(raw), remote)) return undefined; - changed.push({ + return { id, filePath, metadata, currentVersion: metadata.version, latestHubVersion: sortSemVer(versions)[versions.length - 1], - }); - } - - return changed; + kind: 'mapping', + }; + }; } /** @@ -171,9 +258,34 @@ export async function bumpWorkspace( // Tracks the actual increment used for each bumped doc, so cascade passes can inherit it. const appliedIncrements = new Map(); + const narrativeChanges = changed.filter((change): change is Extract => + change.kind === 'narrative' + ); + const narrativeManifest = narrativeChanges.length > 0 ? await loadManifest(bundlePath) : undefined; + for (const change of narrativeChanges) { + const entry = narrativeManifest?.[change.id]; + if (!entry) throw new Error(`Narrative document '${change.id}' is no longer in the manifest.`); + const document = resolveWorkspaceManifestEntry(entry); + if (document.kind !== 'narrative') { + throw new Error(`Narrative document '${change.id}' is no longer a narrative manifest entry.`); + } + const increment = options.perDocIncrements?.get(change.id) ?? options.increment; + narrativeManifest[change.id] = { + ...document.entry, + version: computeSemVerBump(change.latestHubVersion, increment), + }; + } + for (const c of changed) { const docIncrement = options.perDocIncrements?.get(c.id) ?? options.increment; const toVersion = computeSemVerBump(c.latestHubVersion, docIncrement); + if (c.kind === 'narrative') { + bumped.push({ id: c.id, filePath: c.filePath, fromVersion: c.currentVersion, toVersion, increment: docIncrement }); + appliedIncrements.set(c.id, docIncrement); + bumpedIds.add(c.id); + logger.info(`Bumped '${c.id}' ${c.currentVersion} -> ${toVersion}`); + continue; + } const raw = await readFile(c.filePath, 'utf8'); const updated = bumpDocumentContent(raw, { ...c.metadata, version: toVersion }); await writeFile(c.filePath, updated, 'utf8'); @@ -182,6 +294,7 @@ export async function bumpWorkspace( bumpedIds.add(c.id); logger.info(`Bumped '${c.id}' ${c.currentVersion} -> ${toVersion}`); } + if (narrativeManifest) await saveManifest(bundlePath, narrativeManifest); // Cascade: sync refs, then bump any document that was modified by the sync but not yet bumped. // Repeat until nothing new gets changed (fixed-point). Terminates because each iteration adds diff --git a/cli/src/command-helpers/workspace/bundle.spec.ts b/cli/src/command-helpers/workspace/bundle.spec.ts index 2ab5a6049..8670a0883 100644 --- a/cli/src/command-helpers/workspace/bundle.spec.ts +++ b/cli/src/command-helpers/workspace/bundle.spec.ts @@ -8,8 +8,10 @@ import { buildDependencyGraph, printBundleTree, extractReferenceValue, + isNarrativeWorkspaceManifestEntry, MANIFEST_FILENAME, - REFERENCE_PROPERTIES + REFERENCE_PROPERTIES, + type WorkspaceManifestEntry, } from './bundle'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; @@ -50,6 +52,65 @@ describe('bundle', () => { }); }); + describe('WorkspaceManifestEntry', () => { + it('discriminates narrative entries by document type', () => { + const mapping: WorkspaceManifestEntry = { path: 'files/architecture.json', type: 'architecture' }; + const unpublishedNarrative: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', + }; + const publishedNarrative: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/documents/sad/42/versions/1.0.0', + }; + const pendingNarrative: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', + createRecovery: { pending: true }, + }; + + expect(isNarrativeWorkspaceManifestEntry(mapping)).toBe(false); + expect(isNarrativeWorkspaceManifestEntry(unpublishedNarrative)).toBe(true); + expect(isNarrativeWorkspaceManifestEntry(publishedNarrative)).toBe(true); + expect(isNarrativeWorkspaceManifestEntry(pendingNarrative)).toBe(true); + expect([mapping, publishedNarrative].filter(isNarrativeWorkspaceManifestEntry)[0].version).toBe('1.0.0'); + }); + + it('rejects incomplete narrative identity and narrative-owned mapping state at compile time', () => { + // @ts-expect-error Published narrative entries require calmHubId. + const narrativeWithoutHubId: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', calmHubDocumentId: 42, + }; + // @ts-expect-error Published narrative entries require calmHubDocumentId. + const narrativeWithoutDocumentId: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', calmHubId: '/documents/sad/42/versions/1.0.0', + }; + // @ts-expect-error Narrative entries require a manifest version. + const narrativeWithoutVersion: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', + }; + // @ts-expect-error Mapping entries do not own a manifest version. + const invalidMapping: WorkspaceManifestEntry = { + path: 'files/architecture.json', type: 'architecture', version: '1.0.0', + }; + // @ts-expect-error Mapping entries do not own a narrative document ID. + const invalidMappingId: WorkspaceManifestEntry = { + path: 'files/architecture.json', type: 'architecture', calmHubDocumentId: 42, + }; + // @ts-expect-error Pending narrative entries cannot also have a published identity. + const pendingPublishedNarrative: WorkspaceManifestEntry = { + path: 'files/design.md', type: 'sad', version: '1.0.0', + createRecovery: { pending: true }, + calmHubDocumentId: 42, calmHubId: '/documents/sad/42/versions/1.0.0', + }; + + expect(narrativeWithoutHubId.type).toBe('sad'); + expect(narrativeWithoutDocumentId.type).toBe('sad'); + expect(narrativeWithoutVersion.type).toBe('sad'); + expect(invalidMapping.type).toBe('architecture'); + expect(invalidMappingId.type).toBe('architecture'); + expect(pendingPublishedNarrative.type).toBe('sad'); + }); + }); + describe('extractReferenceValue', () => { it('should return string value directly', () => { expect(extractReferenceValue('https://example.com/schema.json')).toBe('https://example.com/schema.json'); @@ -99,6 +160,15 @@ describe('bundle', () => { expect(manifest).toEqual(expected); }); + it('should preserve malformed persisted narrative identity for runtime validation', async () => { + const malformed = { + narrative: { path: 'files/design.md', type: 'sad', calmHubId: '/partial' }, + }; + await writeFile(path.join(bundlePath, MANIFEST_FILENAME), JSON.stringify(malformed)); + + expect(await loadManifest(bundlePath)).toEqual(malformed); + }); + it('should migrate old string-value format to new entry format', async () => { const old = { 'doc1': 'files/doc1.json', 'doc2': 'files/doc2.json' }; await writeFile(path.join(bundlePath, MANIFEST_FILENAME), JSON.stringify(old)); @@ -180,6 +250,7 @@ describe('bundle', () => { describe('addFileToBundle', () => { const srcFile = path.join(testDir, 'source.json'); + const referencedSrcPath = path.relative(bundlePath, srcFile).split(path.sep).join('/'); beforeEach(async () => { await writeFile(srcFile, JSON.stringify({ '$id': 'source-doc', data: 'test' })); @@ -204,6 +275,268 @@ describe('bundle', () => { expect(manifest['source-doc'].type).toBe('architecture'); }); + it('persists a complete narrative Hub identity with its version', async () => { + await addFileToBundle(bundlePath, srcFile, { + type: 'sad', version: '1.2.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/1.2.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toMatchObject({ + version: '1.2.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/1.2.0', + }); + }); + + it('allows a version-only narrative entry for a new document', async () => { + await addFileToBundle(bundlePath, srcFile, { type: 'sad', version: '1.0.0' }); + expect((await loadManifest(bundlePath))['source-doc']).toMatchObject({ version: '1.0.0' }); + }); + + it('keeps an unpublished narrative unpublished when re-added', async () => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '1.0.0', + }, + }); + + await addFileToBundle(bundlePath, srcFile, { + type: 'sad', namespace: 'finos', version: '1.0.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: referencedSrcPath, type: 'sad', namespace: 'finos', version: '1.0.0', + }); + }); + + it('rejects re-adding a narrative while create recovery is pending', async () => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '1.0.0', + createRecovery: { pending: true as const }, + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, { + copy: true, type: 'sad', namespace: 'finos', version: '1.0.0', + })).rejects.toThrow(/pending create recovery/); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + expect(existsSync(path.join(filesPath, 'source.json'))).toBe(false); + }); + + it('rejects replacing a pending narrative with a mapping', async () => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '1.0.0', + createRecovery: { pending: true as const }, + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, { + copy: true, type: 'architecture', + })).rejects.toThrow(/pending create recovery/); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + expect(existsSync(path.join(filesPath, 'source.json'))).toBe(false); + }); + + it('allows a full compatible verified identity to reconcile pending recovery', async () => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '1.0.0', + createRecovery: { pending: true }, + }, + }); + + await addFileToBundle(bundlePath, srcFile, { + type: 'sad', namespace: 'finos', version: '1.0.0', calmHubDocumentId: 3, + calmHubId: '/api/calm/namespaces/finos/documents/sad/3/versions/1.0.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: referencedSrcPath, type: 'sad', namespace: 'finos', version: '1.0.0', + calmHubDocumentId: 3, + calmHubId: '/api/calm/namespaces/finos/documents/sad/3/versions/1.0.0', + }); + expect((await loadManifest(bundlePath))['source-doc']).not.toHaveProperty('createRecovery'); + }); + + it.each([ + { type: 'knowledge' as const, namespace: 'finos', version: '1.0.0' }, + { type: 'sad' as const, namespace: 'other', version: '1.0.0' }, + { type: 'sad' as const, namespace: 'finos', version: '1.1.0' }, + ])('rejects verified identity outside the pending recovery scope', async ({ type, namespace, version }) => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '1.0.0', + createRecovery: { pending: true as const }, + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, { + copy: true, type, namespace, version, calmHubDocumentId: 3, + calmHubId: `/api/calm/namespaces/${namespace}/documents/${type}/3/versions/${version}`, + })).rejects.toThrow(/pending create recovery scope/); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + expect(existsSync(path.join(filesPath, 'source.json'))).toBe(false); + }); + + it('preserves a published narrative Hub identity when re-added normally', async () => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }, + }); + + await addFileToBundle(bundlePath, srcFile, { + type: 'sad', namespace: 'finos', version: '1.0.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: referencedSrcPath, type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }); + }); + + it.each([ + [false, referencedSrcPath], + [true, 'files/source.json'], + ])('preserves a published narrative Hub identity when changing its stored path (copy: %s)', async (copy, expectedPath) => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }, + }); + + await addFileToBundle(bundlePath, srcFile, { + copy, type: 'sad', namespace: 'finos', version: '1.0.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: expectedPath, type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }); + }); + + it('accepts verified recovery when the existing published identity is equivalent', async () => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: 'https://calmhub.example.com/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }, + }); + + await addFileToBundle(bundlePath, srcFile, { + type: 'sad', namespace: 'finos', version: '2.3.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: referencedSrcPath, type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }); + }); + + it('rejects verified recovery that conflicts with an existing published identity', async () => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, { + copy: true, type: 'sad', namespace: 'finos', version: '2.3.0', calmHubDocumentId: 43, + calmHubId: '/api/calm/namespaces/finos/documents/sad/43/versions/2.3.0', + })).rejects.toThrow(/recovery identity conflicts/); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + expect(existsSync(path.join(filesPath, 'source.json'))).toBe(false); + }); + + it.each([ + { type: 'knowledge' as const, namespace: 'finos' }, + { type: 'sad' as const, namespace: 'other' }, + ])('rejects a normal re-add that changes published identity scope', async ({ type, namespace }) => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, { + type, namespace, version: '1.0.0', + })).rejects.toThrow(/type or namespace conflicts/); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + }); + + it('rejects replacing a published narrative with a mapping before copying or changing the manifest', async () => { + await saveManifest(bundlePath, { + 'source-doc': { + path: 'old.md', type: 'sad', namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }, + }); + const manifestPath = path.join(bundlePath, MANIFEST_FILENAME); + const originalManifest = await readFile(manifestPath, 'utf8'); + + await expect(addFileToBundle(bundlePath, srcFile, { + copy: true, type: 'architecture', + })).rejects.toThrow(/cannot be replaced with a non-narrative document/); + + expect(await readFile(manifestPath, 'utf8')).toBe(originalManifest); + expect(existsSync(path.join(filesPath, 'source.json'))).toBe(false); + }); + + it.each([ + ['omitted', undefined], + ['unknown', { type: 'unknown' as const }], + ])('rejects replacing a published narrative when the incoming type is %s', async (_label, options) => { + const existing = { + path: 'old.md', type: 'sad' as const, namespace: 'finos', version: '2.3.0', + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/2.3.0', + }; + await saveManifest(bundlePath, { 'source-doc': existing }); + + await expect(addFileToBundle(bundlePath, srcFile, options)).rejects.toThrow( + /cannot be replaced with a non-narrative document/ + ); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual(existing); + }); + + it('preserves normal replacement behavior for an existing mapping entry', async () => { + await saveManifest(bundlePath, { + 'source-doc': { path: 'old.json', type: 'architecture', namespace: 'finos' }, + }); + + await addFileToBundle(bundlePath, srcFile, { + type: 'pattern', namespace: 'other', + }); + + expect((await loadManifest(bundlePath))['source-doc']).toEqual({ + path: referencedSrcPath, type: 'pattern', namespace: 'other', + }); + }); + + it.each([ + { type: 'sad' as const, version: '1.2.0', calmHubDocumentId: 42 }, + { type: 'sad' as const, version: '1.2.0', calmHubId: '/path' }, + { type: 'sad' as const, calmHubDocumentId: 42, calmHubId: '/path' }, + ])('rejects an incomplete narrative Hub identity', async (options) => { + await expect(addFileToBundle(bundlePath, srcFile, options as never)).rejects.toThrow(/Hub identity/); + }); + it('should copy file when copy option is true', async () => { const result = await addFileToBundle(bundlePath, srcFile, { copy: true }); diff --git a/cli/src/command-helpers/workspace/bundle.ts b/cli/src/command-helpers/workspace/bundle.ts index 898557b50..c4dcc4ade 100644 --- a/cli/src/command-helpers/workspace/bundle.ts +++ b/cli/src/command-helpers/workspace/bundle.ts @@ -3,7 +3,11 @@ import { mkdir, copyFile, readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { JSONPath } from 'jsonpath-plus'; import { printBundleTreeFromGraph } from './tree'; -import type { CalmDocumentType } from '@finos/calm-models/types'; +import { isNarrativeDocumentType, type CalmDocumentType, type NarrativeDocumentType } from '@finos/calm-models/types'; +import { validateNarrativeDocumentLocation } from './narrative-document'; +import { isNarrativeWorkspaceManifestEntry } from './document-kind'; + +export { isNarrativeWorkspaceManifestEntry } from './document-kind'; /** * Property names that can contain document references (URLs or paths) in CALM JSON. @@ -64,15 +68,54 @@ export function extractAllReferences(json: object): string[] { return Array.from(new Set(allRefs)); } -export type WorkspaceDocumentType = CalmDocumentType | 'unknown'; +export type WorkspaceDocumentType = CalmDocumentType | NarrativeDocumentType | 'unknown'; -export type WorkspaceManifestEntry = { +export type MappingWorkspaceManifestEntry = { path: string; - type: WorkspaceDocumentType; + type: CalmDocumentType | 'unknown'; namespace?: string; calmHubId?: string; + version?: never; + calmHubDocumentId?: never; + createRecovery?: never; +}; + +type NarrativeWorkspaceManifestEntryBase = { + path: string; + type: NarrativeDocumentType; + namespace?: string; + version: string; +}; + +export type UnpublishedNarrativeWorkspaceManifestEntry = NarrativeWorkspaceManifestEntryBase & { + calmHubDocumentId?: never; + calmHubId?: never; + createRecovery?: never; }; +export type NarrativeCreateRecovery = { + pending: true; +}; + +export type CreateRecoveryPendingNarrativeWorkspaceManifestEntry = NarrativeWorkspaceManifestEntryBase & { + calmHubDocumentId?: never; + calmHubId?: never; + createRecovery: NarrativeCreateRecovery; +}; + +export type PublishedNarrativeWorkspaceManifestEntry = NarrativeWorkspaceManifestEntryBase & { + calmHubDocumentId: number; + calmHubId: string; + createRecovery?: never; +}; + +export type NarrativeWorkspaceManifestEntry = + | UnpublishedNarrativeWorkspaceManifestEntry + | CreateRecoveryPendingNarrativeWorkspaceManifestEntry + | PublishedNarrativeWorkspaceManifestEntry; + +export type WorkspaceManifestEntry = MappingWorkspaceManifestEntry | NarrativeWorkspaceManifestEntry; + export type WorkspaceManifest = Record; export type DependencyGraph = { @@ -155,6 +198,83 @@ export async function determineDocumentId(srcPath: string, explicitId?: string): return path.basename(srcPath, path.extname(srcPath)); } +type AddFileToBundleCommonOptions = { + id?: string; + destName?: string; + copy?: boolean; + namespace?: string; +}; + +type AddMappingFileToBundleOptions = AddFileToBundleCommonOptions & { + type?: CalmDocumentType | 'unknown'; + version?: never; + calmHubDocumentId?: never; + calmHubId?: never; +}; + +type AddNarrativeFileToBundleOptions = AddFileToBundleCommonOptions & { + type: NarrativeDocumentType; + version: string; +} & ( + | { calmHubDocumentId?: never; calmHubId?: never } + | { calmHubDocumentId: number; calmHubId: string } +); + +type AddFileToBundleOptions = AddMappingFileToBundleOptions | AddNarrativeFileToBundleOptions; + +function isNarrativeAddFileToBundleOptions( + opts: AddFileToBundleOptions | undefined +): opts is AddNarrativeFileToBundleOptions { + return opts !== undefined && isNarrativeDocumentType(opts.type); +} + +function isPublishedNarrativeWorkspaceManifestEntry( + entry: WorkspaceManifestEntry | undefined +): entry is PublishedNarrativeWorkspaceManifestEntry { + return entry !== undefined && + isNarrativeWorkspaceManifestEntry(entry) && + entry.calmHubDocumentId !== undefined && + entry.calmHubId !== undefined; +} + +function hasNarrativeCreateRecovery( + entry: WorkspaceManifestEntry | undefined +): entry is CreateRecoveryPendingNarrativeWorkspaceManifestEntry { + return entry !== undefined && + isNarrativeWorkspaceManifestEntry(entry) && + Object.prototype.hasOwnProperty.call(entry, 'createRecovery'); +} + +function hasEquivalentPublishedNarrativeIdentity( + entry: PublishedNarrativeWorkspaceManifestEntry, + opts: AddNarrativeFileToBundleOptions & { calmHubDocumentId: number; calmHubId: string } +): boolean { + if ( + entry.namespace === undefined || + opts.namespace === undefined || + entry.namespace !== opts.namespace || + entry.type !== opts.type || + entry.version !== opts.version || + entry.calmHubDocumentId !== opts.calmHubDocumentId + ) { + return false; + } + + const identity = { + namespace: entry.namespace, + type: entry.type, + version: entry.version, + calmHubDocumentId: entry.calmHubDocumentId, + }; + try { + validateNarrativeDocumentLocation(entry.calmHubId, identity); + validateNarrativeDocumentLocation(opts.calmHubId, identity); + return true; + } catch { + return false; + } +} + /** * Add a file into the workspace bundle and register it in the bundle manifest. * The file is copied into the bundle's 'files/' directory and the manifest is updated @@ -168,10 +288,79 @@ export async function determineDocumentId(srcPath: string, explicitId?: string): export async function addFileToBundle( bundlePath: string, srcPath: string, - opts?: { id?: string; destName?: string; copy?: boolean; type?: WorkspaceDocumentType; namespace?: string } + opts?: AddFileToBundleOptions ): Promise<{ id: string; destPath: string; rel: string }> { + const hasDocumentId = opts?.calmHubDocumentId !== undefined; + const hasHubId = opts?.calmHubId !== undefined; + if (hasDocumentId !== hasHubId || ((hasDocumentId || hasHubId) && !opts?.version)) { + throw new Error('Narrative document Hub identity requires calmHubDocumentId, calmHubId, and version.'); + } + const id = await determineDocumentId(srcPath, opts?.id); + const manifest = await loadManifest(bundlePath); + const existingEntry = manifest[id]; + let narrativeIdentity: Pick | undefined; + + if (hasNarrativeCreateRecovery(existingEntry)) { + if ( + !isNarrativeAddFileToBundleOptions(opts) || + opts.calmHubDocumentId === undefined || + opts.calmHubId === undefined + ) { + throw new Error(`Narrative document '${id}' has pending create recovery and cannot be re-added until it is reconciled.`); + } + if ( + existingEntry.type !== opts.type || + existingEntry.namespace !== opts.namespace || + existingEntry.version !== opts.version + ) { + throw new Error(`Narrative document '${id}' recovery identity conflicts with its pending create recovery scope.`); + } + try { + validateNarrativeDocumentLocation(opts.calmHubId, { + namespace: opts.namespace ?? '', + type: opts.type, + version: opts.version, + calmHubDocumentId: opts.calmHubDocumentId, + }); + } catch { + throw new Error(`Narrative document '${id}' recovery identity conflicts with its pending create recovery scope.`); + } + } + + if ( + isPublishedNarrativeWorkspaceManifestEntry(existingEntry) && + !isNarrativeAddFileToBundleOptions(opts) + ) { + throw new Error(`Published narrative document '${id}' cannot be replaced with a non-narrative document.`); + } + + if (isNarrativeAddFileToBundleOptions(opts)) { + if (opts.calmHubDocumentId !== undefined && opts.calmHubId !== undefined) { + if ( + isPublishedNarrativeWorkspaceManifestEntry(existingEntry) && + !hasEquivalentPublishedNarrativeIdentity(existingEntry, opts) + ) { + throw new Error(`Narrative document '${id}' recovery identity conflicts with its existing published Hub identity.`); + } + narrativeIdentity = { + version: opts.version, + calmHubDocumentId: opts.calmHubDocumentId, + calmHubId: opts.calmHubId, + }; + } else if (isPublishedNarrativeWorkspaceManifestEntry(existingEntry)) { + if (existingEntry.type !== opts.type || existingEntry.namespace !== opts.namespace) { + throw new Error(`Narrative document '${id}' type or namespace conflicts with its existing published Hub identity.`); + } + narrativeIdentity = { + version: existingEntry.version, + calmHubDocumentId: existingEntry.calmHubDocumentId, + calmHubId: existingEntry.calmHubId, + }; + } + } + let rel: string; let destPath: string; @@ -191,8 +380,24 @@ export async function addFileToBundle( rel = path.relative(bundlePath, destPath).split(path.sep).join('/'); } - const manifest = await loadManifest(bundlePath); - manifest[id] = { path: rel, type: opts?.type ?? 'unknown', ...(opts?.namespace ? { namespace: opts.namespace } : {}) }; + if (isNarrativeAddFileToBundleOptions(opts)) { + const hubIdentity = narrativeIdentity + ? { calmHubDocumentId: narrativeIdentity.calmHubDocumentId, calmHubId: narrativeIdentity.calmHubId } + : {}; + manifest[id] = { + path: rel, + type: opts.type, + ...(opts.namespace ? { namespace: opts.namespace } : {}), + version: narrativeIdentity?.version ?? opts.version, + ...hubIdentity, + }; + } else { + manifest[id] = { + path: rel, + type: opts?.type ?? 'unknown', + ...(opts?.namespace ? { namespace: opts.namespace } : {}), + }; + } await saveManifest(bundlePath, manifest); return { id, destPath, rel }; diff --git a/cli/src/command-helpers/workspace/commands.spec.ts b/cli/src/command-helpers/workspace/commands.spec.ts index a3fe64688..153e4b531 100644 --- a/cli/src/command-helpers/workspace/commands.spec.ts +++ b/cli/src/command-helpers/workspace/commands.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Command } from 'commander'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST } from '@finos/calm-models/types'; import { setupWorkspaceCommands } from './commands'; const mocks = vi.hoisted(() => { @@ -26,7 +27,12 @@ const mocks = vi.hoisted(() => { loadCliConfig: vi.fn(async () => ({ calmHubUrl: 'https://calmhub.example.com' })), loadAuthPlugin: vi.fn(async () => ({ getAuthHeaders: vi.fn(async () => ({})) })), CalmHubClient: vi.fn().mockImplementation(function() { - return { isMockClient: true }; + return { + isMockClient: true, + getNarrativeDocumentVersion: vi.fn(async () => ({ documentMarkdown: '---\ntitle: Payments SAD\n---\n# Payments\n' })), + createNarrativeDocument: vi.fn(), + createNarrativeDocumentVersion: vi.fn(), + }; }), select: vi.fn(async () => 'architecture'), input: vi.fn(async () => 'prompted-name'), @@ -163,6 +169,12 @@ describe('setupWorkspaceCommands', () => { const CONFORMANT_ID = 'https://calmhub.example.com/calm/namespaces/ns/architectures/my-arch/versions/1.0.0'; describe('workspace add', () => { + it('derives narrative Commander choices from the canonical list', () => { + const add = program.commands.find(command => command.name() === 'workspace')!.commands + .find(command => command.name() === 'add')!; + const typeOption = add.options.find(option => option.flags.includes('--type')) as unknown as { argChoices: string[] }; + expect(typeOption.argChoices).toEqual(expect.arrayContaining(CALM_NARRATIVE_DOCUMENT_TYPES_LIST)); + }); it('builds a $id when the file has none, writes it back, and adds with the derived namespace', async () => { // readFile mock returns JSON with title 'My Architecture' and no $id. await program.parseAsync(['node', 'test', 'workspace', 'add', 'test.json']); @@ -218,6 +230,149 @@ describe('setupWorkspaceCommands', () => { ); }); + it('registers Markdown using its frontmatter title without rewriting it', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments\n'; + mocks.readFile.mockResolvedValueOnce(markdown); + + await program.parseAsync(['node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos']); + + expect(mocks.writeFile).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).toHaveBeenCalledWith( + '/fake/bundle', + expect.stringContaining('payments.md'), + expect.objectContaining({ id: 'Payments SAD', type: 'sad', namespace: 'finos', version: '1.0.0' }) + ); + }); + + it.each([ + ['1', 1], + ['42', 42], + ['123456', 123456], + ])('recovers a verified narrative document with canonical ID %s', async (rawDocumentId, documentId) => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments\n'; + mocks.readFile.mockResolvedValueOnce(markdown); + + await program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', rawDocumentId, '--ver', '1.2.0', '--calm-hub-url', 'https://explicit.example.com' + ]); + + expect(mocks.CalmHubClient).toHaveBeenCalledWith(expect.objectContaining({ calmHubUrl: 'https://explicit.example.com' })); + const client = mocks.CalmHubClient.mock.results[0].value; + expect(client.getNarrativeDocumentVersion).toHaveBeenCalledWith('finos', 'sad', documentId, '1.2.0'); + expect(client.createNarrativeDocument).not.toHaveBeenCalled(); + expect(client.createNarrativeDocumentVersion).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).toHaveBeenCalledWith('/fake/bundle', expect.stringContaining('payments.md'), expect.objectContaining({ + id: 'Payments SAD', type: 'sad', namespace: 'finos', version: '1.2.0', calmHubDocumentId: documentId, + calmHubId: `/api/calm/namespaces/finos/documents/sad/${documentId}/versions/1.2.0`, + })); + }); + + it('uses configured CalmHub URL and authentication for recovery', async () => { + const authPlugin = { getAuthHeaders: vi.fn(async () => ({})) }; + mocks.loadCliConfig.mockResolvedValueOnce({ calmHubUrl: 'https://configured.example.com', authPluginPath: 'auth.ts' }); + mocks.loadAuthPlugin.mockResolvedValueOnce(authPlugin); + mocks.readFile.mockResolvedValueOnce('---\ntitle: Payments SAD\n---\n# Payments\n'); + + await program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', '1.2.0' + ]); + + expect(mocks.CalmHubClient).toHaveBeenCalledWith({ calmHubUrl: 'https://configured.example.com', authPlugin }); + }); + + it('rejects recovery when neither an explicit nor configured Hub URL exists', async () => { + mocks.loadCliConfig.mockResolvedValueOnce(null); + mocks.readFile.mockResolvedValueOnce('---\ntitle: Payments SAD\n---\n# Payments\n'); + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', '1.2.0' + ])).rejects.toThrow(); + expect(mocks.loadCliConfig).toHaveBeenCalled(); + expect(mocks.CalmHubClient).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + + it.each(['0', '-1', '1.5', '1e2', ' 42', '42 ', '01', '+42', '9007199254740992'])( + 'rejects non-canonical or unsafe narrative recovery ID %j', async (documentId) => { + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', documentId, '--ver', '1.2.0' + ])).rejects.toThrow(); + expect(mocks.CalmHubClient).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + } + ); + + it('rejects an invalid recovery version', async () => { + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', 'invalid' + ])).rejects.toThrow(); + expect(mocks.CalmHubClient).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + + it('preserves --id as the recovery manifest key', async () => { + mocks.readFile.mockResolvedValueOnce('---\ntitle: Payments SAD\n---\n# Payments\n'); + await program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--id', 'payments-archive', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', '1.2.0', '--calm-hub-url', 'https://explicit.example.com' + ]); + expect(mocks.addFileToBundle).toHaveBeenCalledWith('/fake/bundle', expect.any(String), expect.objectContaining({ id: 'payments-archive' })); + }); + + it.each([ + ['--calm-hub-document-id', '42'], + ['--ver', '1.2.0'], + ['--calm-hub-url', 'https://explicit.example.com'], + ])('rejects incomplete narrative recovery options (%s)', async (option, value) => { + await expect(program.parseAsync(['node', 'test', 'workspace', 'add', 'payments.md', option, value])).rejects.toThrow(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + expect(mocks.CalmHubClient).not.toHaveBeenCalled(); + }); + + it('rejects recovery without a narrative type or namespace before Hub calls', async () => { + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'architecture', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', '1.2.0' + ])).rejects.toThrow(); + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', + '--calm-hub-document-id', '42', '--ver', '1.2.0' + ])).rejects.toThrow(); + expect(mocks.CalmHubClient).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + + it('does not change the manifest when recovered Markdown differs', async () => { + mocks.readFile.mockResolvedValueOnce('---\ntitle: Payments SAD\n---\n# Local\n'); + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + '--calm-hub-document-id', '42', '--ver', '1.2.0' + ])).rejects.toThrow(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + + it('requires a namespace when adding a narrative document', async () => { + await expect( + program.parseAsync(['node', 'test', 'workspace', 'add', 'payments.md', '--type', 'knowledge']) + ).rejects.toThrow(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + + it('does not mutate or register Markdown with malformed frontmatter', async () => { + mocks.readFile.mockResolvedValueOnce('---\ntitle: [\n---\n# Payments\n'); + + await expect( + program.parseAsync(['node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos']) + ).rejects.toThrow(); + + expect(mocks.writeFile).not.toHaveBeenCalled(); + expect(mocks.addFileToBundle).not.toHaveBeenCalled(); + }); + it('should exit when no workspace bundle found', async () => { mocks.findWorkspaceManifestPath.mockReturnValueOnce(null); await expect( diff --git a/cli/src/command-helpers/workspace/commands.ts b/cli/src/command-helpers/workspace/commands.ts index 3b265115b..74c0de9f4 100644 --- a/cli/src/command-helpers/workspace/commands.ts +++ b/cli/src/command-helpers/workspace/commands.ts @@ -13,12 +13,172 @@ import { loadWorkspaceConfig } from './config'; import { findWorkspaceManifestPath, findProjectRoot } from '../../workspace-resolver'; import { initLogger, Logger, CalmHubClient, ResourceChangeType, isConformantDocumentId, namespaceFromDocumentId } from '@finos/calm-shared'; import { select, input } from '@inquirer/prompts'; -import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; +import { + CALM_DOCUMENT_TYPES_LIST, + CALM_NARRATIVE_DOCUMENT_TYPES_LIST, + isValidCalmDocumentType, + type CalmDocumentType, +} from '@finos/calm-models/types'; import { loadCliConfig } from '../../cli-config'; import { resolveCalmHubOptions } from '../hub-commands'; +import { constructNarrativeDocumentPath, parseNarrativeDocument, validateNarrativeIdentity, type NarrativeDocumentIdentity } from './narrative-document'; +import { + dispatchWorkspaceDocumentType, + resolveWorkspaceDocumentType, + type WorkspaceDocumentTypeOperations, +} from './document-kind'; const logger: Logger = initLogger(false, 'workspace'); +type NarrativeRegistrationOptions = { + id?: string; + copy?: boolean; + identity: NarrativeDocumentIdentity; + verify?: (documentMarkdown: string) => Promise; +}; + +type WorkspaceAddOptions = { + id?: string; + copy?: boolean; + type?: string; + namespace?: string; + calmHubDocumentId?: string; + ver?: string; + calmHubUrl?: string; +}; + +interface AddDocumentContext { + bundlePath: string; + file: string; + options: WorkspaceAddOptions; + srcPath: string; +} + +async function registerNarrativeDocument( + bundlePath: string, + srcPath: string, + file: string, + options: NarrativeRegistrationOptions +): Promise<{ id: string; destPath: string; rel: string }> { + const raw = await readFile(srcPath, 'utf8'); + const narrative = parseNarrativeDocument(raw, file); + await options.verify?.(raw); + + const hubIdentity = options.identity.calmHubDocumentId === undefined + ? {} + : { + calmHubDocumentId: options.identity.calmHubDocumentId, + calmHubId: constructNarrativeDocumentPath(options.identity), + }; + + return addFileToBundle(bundlePath, srcPath, { + id: options.id ?? narrative.request.name, + copy: options.copy, + type: options.identity.type, + namespace: options.identity.namespace, + version: options.identity.version, + ...hubIdentity, + }); +} + +const ADD_DOCUMENT_OPERATIONS = { + mapping: addMappingDocument, + narrative: addNarrativeDocument, +} satisfies WorkspaceDocumentTypeOperations, [AddDocumentContext]>; + +async function addNarrativeDocument( + type: NarrativeDocumentIdentity['type'], + context: AddDocumentContext +): Promise { + const { bundlePath, file, options, srcPath } = context; + if (!options.namespace?.trim()) { + throw new Error(`Narrative document '${file}' requires --namespace.`); + } + const { id: resolvedId, destPath: finalDestPath } = await registerNarrativeDocument( + bundlePath, + srcPath, + file, + { + id: options.id, + copy: options.copy, + identity: { + namespace: options.namespace.trim(), + type, + version: '1.0.0', + }, + } + ); + if (options.copy) { + logger.info(`Copied ${srcPath} -> ${finalDestPath} (id: ${resolvedId})`); + } else { + logger.info(`Added reference to ${finalDestPath} (id: ${resolvedId})`); + } +} + +async function addMappingDocument( + type: CalmDocumentType | 'unknown', + context: AddDocumentContext +): Promise { + const { bundlePath, options, srcPath } = context; + if (!isValidCalmDocumentType(type)) { + throw new Error(`Invalid document type '${type}'. Must be one of: ${CALM_DOCUMENT_TYPES_LIST.join(', ')}`); + } + + // Parse the file once; we manage its $id only when it is valid JSON. + let fileJson: Record | undefined; + try { + fileJson = JSON.parse(await readFile(srcPath, 'utf8')); + } catch (_) { + fileJson = undefined; + } + + const baseUrlDefault = (await loadCliConfig())?.calmHubUrl; + const existingId = fileJson && typeof fileJson['$id'] === 'string' ? (fileJson['$id'] as string) : undefined; + let builtNamespace: string | undefined; + let effectiveId = existingId; + + if (fileJson) { + if (!existingId) { + // No $id present: build one interactively and write it into the file. + const built = await promptForDocumentId({ baseUrlDefault }); + fileJson['$id'] = built.id; + await writeFile(srcPath, JSON.stringify(fileJson, null, 2), 'utf8'); + logger.info(`Set document $id to ${built.id}`); + builtNamespace = built.namespace; + effectiveId = built.id; + } else if (!isConformantDocumentId(existingId)) { + // Non-conformant $id: warn but still add — push will skip non-pushable types anyway. + // Silently rewriting would be data loss for types that don't use CalmHub URLs (flow, adr, timeline, etc.). + logger.warn(`Document $id '${existingId}' is not a conformant CalmHub id. The document will be tracked but cannot be pushed to CalmHub.`); + } + } + + const namespace = options.namespace + ?? builtNamespace + ?? (effectiveId ? namespaceFromDocumentId(effectiveId) : undefined); + + let id = options.id; + if (!id && fileJson && typeof fileJson['title'] === 'string' && (fileJson['title'] as string).trim()) { + id = (fileJson['title'] as string).trim(); + } + if (!id) { + id = await input({ message: 'Enter a name for this document:' }); + } + + const { id: resolvedId, destPath: finalDestPath } = await addFileToBundle(bundlePath, srcPath, { + id, + copy: options.copy, + type, + namespace: namespace?.trim() + }); + + if (options.copy) { + logger.info(`Copied ${srcPath} -> ${finalDestPath} (id: ${resolvedId})`); + } else { + logger.info(`Added reference to ${finalDestPath} (id: ${resolvedId})`); + } +} + /** * Sets up the 'workspace' command and its subcommands in the CLI. * @param program The Commander.js top-level program. @@ -52,9 +212,12 @@ export function setupWorkspaceCommands(program: Command) { .argument('', 'Path to the file to add to the bundle') .option('--id ', 'Document ID to register for this file (defaults to filename without extension)') .option('--copy', 'Copy the file into the bundle instead of referencing it from its current location.') - .addOption(new Option('--type ', 'Document type').choices([...CALM_DOCUMENT_TYPES_LIST])) + .addOption(new Option('--type ', 'Document type').choices([...CALM_DOCUMENT_TYPES_LIST, ...CALM_NARRATIVE_DOCUMENT_TYPES_LIST])) .option('--namespace ', 'CalmHub namespace to associate with this file') - .action(async (file: string, options: { id?: string; copy?: boolean; type?: string; namespace?: string }) => { + .option('--calm-hub-document-id ', 'Existing CalmHub narrative document ID') + .option('--ver ', 'Existing CalmHub narrative document version') + .option('--calm-hub-url ', 'CalmHub URL used to verify an existing narrative document') + .action(async (file: string, options: WorkspaceAddOptions) => { try { const bundlePath = findWorkspaceManifestPath(process.cwd()); if (!bundlePath) { @@ -64,65 +227,76 @@ export function setupWorkspaceCommands(program: Command) { const srcPath = path.resolve(file); - const type = await enforceOptionPresenceByPrompt(options.type, 'Select a document type:', CALM_DOCUMENT_TYPES_LIST); - if (!isValidCalmDocumentType(type)) { - logger.error(`Invalid document type '${type}'. Must be one of: ${CALM_DOCUMENT_TYPES_LIST.join(', ')}`); - process.exit(1); - } - - // Parse the file once; we manage its $id only when it is valid JSON. - let fileJson: Record | undefined; - try { - fileJson = JSON.parse(await readFile(srcPath, 'utf8')); - } catch (_) { - fileJson = undefined; - } - - const baseUrlDefault = (await loadCliConfig())?.calmHubUrl; - const existingId = fileJson && typeof fileJson['$id'] === 'string' ? (fileJson['$id'] as string) : undefined; - let builtNamespace: string | undefined; - let effectiveId = existingId; - - if (fileJson) { - if (!existingId) { - // No $id present: build one interactively and write it into the file. - const built = await promptForDocumentId({ baseUrlDefault }); - fileJson['$id'] = built.id; - await writeFile(srcPath, JSON.stringify(fileJson, null, 2), 'utf8'); - logger.info(`Set document $id to ${built.id}`); - builtNamespace = built.namespace; - effectiveId = built.id; - } else if (!isConformantDocumentId(existingId)) { - // Non-conformant $id: warn but still add — push will skip non-pushable types anyway. - // Silently rewriting would be data loss for types that don't use CalmHub URLs (flow, adr, timeline, etc.). - logger.warn(`Document $id '${existingId}' is not a conformant CalmHub id. The document will be tracked but cannot be pushed to CalmHub.`); + const hasDocumentId = options.calmHubDocumentId !== undefined; + const hasVersion = options.ver !== undefined; + const hasHubUrl = options.calmHubUrl !== undefined; + const recoveryRequested = hasDocumentId || hasVersion || hasHubUrl; + if (recoveryRequested) { + if (!hasDocumentId || !hasVersion) { + throw new Error('Narrative recovery requires both --calm-hub-document-id and --ver.'); + } + if (!options.type) { + throw new Error('Narrative recovery requires a narrative --type.'); + } + const recoveredType = resolveWorkspaceDocumentType(options.type); + if (recoveredType?.kind !== 'narrative') { + throw new Error('Narrative recovery requires a narrative --type.'); + } + if (!options.namespace?.trim()) { + throw new Error(`Narrative document '${file}' recovery requires --namespace.`); } - } - - const namespace = options.namespace - ?? builtNamespace - ?? (effectiveId ? namespaceFromDocumentId(effectiveId) : undefined); - let id = options.id; - if (!id && fileJson && typeof fileJson['title'] === 'string' && (fileJson['title'] as string).trim()) { - id = (fileJson['title'] as string).trim(); - } - if (!id) { - id = await input({ message: 'Enter a name for this document:' }); + const rawDocumentId = options.calmHubDocumentId; + if (typeof rawDocumentId !== 'string' || !/^[1-9]\d*$/.test(rawDocumentId)) { + throw new Error(`Narrative document '${file}' calmHubDocumentId must be a positive integer.`); + } + const calmHubDocumentId = Number(rawDocumentId); + if (!Number.isSafeInteger(calmHubDocumentId)) { + throw new Error(`Narrative document '${file}' calmHubDocumentId must be a positive integer.`); + } + const identity = { + namespace: options.namespace.trim(), + type: recoveredType.type, + version: options.ver, + calmHubDocumentId, + }; + validateNarrativeIdentity(identity, true, file); + const { id: resolvedId, destPath: finalDestPath } = await registerNarrativeDocument( + bundlePath, + srcPath, + file, + { + id: options.id, + copy: options.copy, + identity, + verify: async (raw) => { + const calmHubOptions = await resolveCalmHubOptions({ calmHubUrl: options.calmHubUrl }); + const client = new CalmHubClient(calmHubOptions); + const remote = await client.getNarrativeDocumentVersion( + identity.namespace, identity.type, identity.calmHubDocumentId, identity.version + ); + if (remote.documentMarkdown !== raw) { + throw new Error(`Narrative document '${file}' does not match CalmHub version ${identity.version}.`); + } + }, + } + ); + logger.info(`${options.copy ? 'Copied' : 'Added reference to'} ${finalDestPath} (id: ${resolvedId})`); + return; } - const { id: resolvedId, destPath: finalDestPath } = await addFileToBundle(bundlePath, srcPath, { - id, - copy: options.copy, - type, - namespace: namespace?.trim() - }); - - if (options.copy) { - logger.info(`Copied ${srcPath} -> ${finalDestPath} (id: ${resolvedId})`); - } else { - logger.info(`Added reference to ${finalDestPath} (id: ${resolvedId})`); + const documentTypes = [...CALM_DOCUMENT_TYPES_LIST, ...CALM_NARRATIVE_DOCUMENT_TYPES_LIST]; + const type = await enforceOptionPresenceByPrompt(options.type, 'Select a document type:', documentTypes); + const resolvedType = resolveWorkspaceDocumentType(type); + if (!resolvedType) { + logger.error(`Invalid document type '${type}'. Must be one of: ${CALM_DOCUMENT_TYPES_LIST.join(', ')}`); + process.exit(1); } + await dispatchWorkspaceDocumentType( + resolvedType, + ADD_DOCUMENT_OPERATIONS, + { bundlePath, file, options, srcPath } + ); } catch (err) { logger.error('Failed to add file to workspace bundle: ' + (err instanceof Error ? err.message : String(err))); process.exit(1); diff --git a/cli/src/command-helpers/workspace/document-kind.spec.ts b/cli/src/command-helpers/workspace/document-kind.spec.ts new file mode 100644 index 000000000..558b4530c --- /dev/null +++ b/cli/src/command-helpers/workspace/document-kind.spec.ts @@ -0,0 +1,95 @@ +import { + dispatchWorkspaceManifestEntry, + getJsonReferenceWorkspaceManifest, + resolveWorkspaceDocumentType, + resolveWorkspaceManifestEntry, + WORKSPACE_DOCUMENT_HANDLERS, + type WorkspaceManifestEntryOperations, +} from './document-kind'; +import type { WorkspaceManifest, WorkspaceManifestEntry } from './bundle'; +import { + CALM_DOCUMENT_TYPES_LIST, + CALM_NARRATIVE_DOCUMENT_TYPES_LIST, +} from '@finos/calm-models/types'; + +describe('workspace document handlers', () => { + it('resolves mapping and narrative entries through the central handler record', () => { + const mapping: WorkspaceManifestEntry = { path: 'architecture.json', type: 'architecture' }; + const narrative: WorkspaceManifestEntry = { + path: 'decision.md', + type: 'sad', + namespace: 'example', + version: '1.0.0', + }; + + expect(resolveWorkspaceManifestEntry(mapping)).toEqual({ + kind: 'mapping', + handler: WORKSPACE_DOCUMENT_HANDLERS.mapping, + entry: mapping, + }); + expect(resolveWorkspaceManifestEntry(narrative)).toEqual({ + kind: 'narrative', + handler: WORKSPACE_DOCUMENT_HANDLERS.narrative, + entry: narrative, + }); + expect(resolveWorkspaceDocumentType('architecture')?.handler).toBe(WORKSPACE_DOCUMENT_HANDLERS.mapping); + expect(resolveWorkspaceDocumentType('sad')?.handler).toBe(WORKSPACE_DOCUMENT_HANDLERS.narrative); + }); + + it('intentionally retains mapping behavior for the legacy unknown type', () => { + const unknown: WorkspaceManifestEntry = { path: 'legacy.json', type: 'unknown' }; + + expect(resolveWorkspaceManifestEntry(unknown)).toEqual({ + kind: 'mapping', + handler: WORKSPACE_DOCUMENT_HANDLERS.mapping, + entry: unknown, + }); + expect(resolveWorkspaceDocumentType('unknown')?.handler).toBe(WORKSPACE_DOCUMENT_HANDLERS.mapping); + }); + + it('does not assign unsupported future types to the mapping strategy', () => { + expect(resolveWorkspaceDocumentType('future-document-kind')).toBeUndefined(); + }); + + it('keeps the handler table exhaustive for every classified document kind', () => { + const resolvedKinds = [...CALM_DOCUMENT_TYPES_LIST, ...CALM_NARRATIVE_DOCUMENT_TYPES_LIST] + .map(type => resolveWorkspaceDocumentType(type)?.kind); + + expect(new Set(resolvedKinds)).toEqual(new Set(Object.keys(WORKSPACE_DOCUMENT_HANDLERS))); + expect(resolvedKinds).not.toContain(undefined); + }); + + it('selects only handlers that support JSON reference processing', () => { + const manifest: WorkspaceManifest = { + architecture: { path: 'architecture.json', type: 'architecture' }, + decision: { + path: 'decision.md', + type: 'sad', + namespace: 'example', + version: '1.0.0', + }, + }; + + expect(getJsonReferenceWorkspaceManifest(manifest)).toEqual({ + architecture: manifest.architecture, + }); + }); + + it('dispatches every registered kind through an exhaustive operation table', () => { + const operations = { + mapping: entry => `mapping:${entry.type}`, + narrative: entry => `narrative:${entry.type}`, + } satisfies WorkspaceManifestEntryOperations; + + const mapping = resolveWorkspaceManifestEntry({ path: 'architecture.json', type: 'architecture' }); + const narrative = resolveWorkspaceManifestEntry({ + path: 'decision.md', + type: 'sad', + namespace: 'example', + version: '1.0.0', + }); + + expect(dispatchWorkspaceManifestEntry(mapping, operations)).toBe('mapping:architecture'); + expect(dispatchWorkspaceManifestEntry(narrative, operations)).toBe('narrative:sad'); + }); +}); diff --git a/cli/src/command-helpers/workspace/document-kind.ts b/cli/src/command-helpers/workspace/document-kind.ts new file mode 100644 index 000000000..53f5de282 --- /dev/null +++ b/cli/src/command-helpers/workspace/document-kind.ts @@ -0,0 +1,128 @@ +import { + type CalmDocumentType, + type NarrativeDocumentType, +} from '@finos/calm-models/types'; +import { + classifyWorkspaceDocumentType, + type WorkspaceDocumentKind, +} from '@finos/calm-shared'; +import type { + MappingWorkspaceManifestEntry, + NarrativeWorkspaceManifestEntry, + WorkspaceManifest, + WorkspaceManifestEntry, +} from './bundle'; + +export const WORKSPACE_DOCUMENT_HANDLERS = { + mapping: { + unreadableFile: 'warn', + supportsJsonReferences: true, + }, + narrative: { + unreadableFile: 'fail', + supportsJsonReferences: false, + }, +} as const satisfies Record; + +export type ResolvedWorkspaceDocumentType = + | { kind: 'mapping'; handler: typeof WORKSPACE_DOCUMENT_HANDLERS.mapping; type: CalmDocumentType | 'unknown' } + | { kind: 'narrative'; handler: typeof WORKSPACE_DOCUMENT_HANDLERS.narrative; type: NarrativeDocumentType }; + +export type ResolvedWorkspaceManifestEntry = + | { kind: 'mapping'; handler: typeof WORKSPACE_DOCUMENT_HANDLERS.mapping; entry: MappingWorkspaceManifestEntry } + | { kind: 'narrative'; handler: typeof WORKSPACE_DOCUMENT_HANDLERS.narrative; entry: NarrativeWorkspaceManifestEntry }; + +export type WorkspaceDocumentTypeOperations = { + [K in WorkspaceDocumentKind]: ( + type: Extract['type'], + ...args: TArgs + ) => TResult; +}; + +export type WorkspaceManifestEntryOperations = { + [K in WorkspaceDocumentKind]: ( + entry: Extract['entry'], + ...args: TArgs + ) => TResult; +}; + +/** Resolve only explicitly supported workspace types plus the legacy literal `unknown`. */ +export function resolveWorkspaceDocumentType(type: unknown): ResolvedWorkspaceDocumentType | undefined { + const document = classifyWorkspaceDocumentType(type); + if (document === undefined) return undefined; + switch (document.kind) { + case 'mapping': + return { ...document, handler: WORKSPACE_DOCUMENT_HANDLERS.mapping }; + case 'narrative': + return { ...document, handler: WORKSPACE_DOCUMENT_HANDLERS.narrative }; + default: + return assertNever(document); + } +} + +export function isNarrativeWorkspaceManifestEntry( + entry: WorkspaceManifestEntry +): entry is NarrativeWorkspaceManifestEntry { + return resolveWorkspaceDocumentType(entry.type)?.kind === 'narrative'; +} + +export function resolveWorkspaceManifestEntry( + entry: WorkspaceManifestEntry +): ResolvedWorkspaceManifestEntry { + if (isNarrativeWorkspaceManifestEntry(entry)) { + return { kind: 'narrative', handler: WORKSPACE_DOCUMENT_HANDLERS.narrative, entry }; + } + if (resolveWorkspaceDocumentType(entry.type)?.kind !== 'mapping') { + throw new Error(`Unsupported workspace document type '${String(entry.type)}'.`); + } + return { kind: 'mapping', handler: WORKSPACE_DOCUMENT_HANDLERS.mapping, entry }; +} + +export function dispatchWorkspaceDocumentType( + document: ResolvedWorkspaceDocumentType, + operations: WorkspaceDocumentTypeOperations, + ...args: TArgs +): TResult { + switch (document.kind) { + case 'mapping': + return operations.mapping(document.type, ...args); + case 'narrative': + return operations.narrative(document.type, ...args); + default: + return assertNever(document); + } +} + +export function dispatchWorkspaceManifestEntry( + document: ResolvedWorkspaceManifestEntry, + operations: WorkspaceManifestEntryOperations, + ...args: TArgs +): TResult { + switch (document.kind) { + case 'mapping': + return operations.mapping(document.entry, ...args); + case 'narrative': + return operations.narrative(document.entry, ...args); + default: + return assertNever(document); + } +} + +/** Keep non-JSON workspace documents out of mapping reference reads and rewrites. */ +export function getJsonReferenceWorkspaceManifest(manifest: WorkspaceManifest): WorkspaceManifest { + const result: WorkspaceManifest = {}; + for (const [id, entry] of Object.entries(manifest)) { + const resolved = resolveWorkspaceManifestEntry(entry); + if (resolved.handler.supportsJsonReferences) { + result[id] = resolved.entry; + } + } + return result; +} + +function assertNever(value: never): never { + throw new Error(`Unsupported workspace document kind '${String(value)}'.`); +} diff --git a/cli/src/command-helpers/workspace/narrative-document.spec.ts b/cli/src/command-helpers/workspace/narrative-document.spec.ts new file mode 100644 index 000000000..03a731a02 --- /dev/null +++ b/cli/src/command-helpers/workspace/narrative-document.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST } from '@finos/calm-models/types'; +import { + constructNarrativeDocumentPath, + parseNarrativeDocument, + parseNarrativeDocumentLocation, + resolveNarrativeEntry, + validateNarrativeDocumentLocation, + validateNarrativeIdentity, +} from './narrative-document'; + +describe('narrative document helpers', () => { + const identity = { namespace: 'finos', type: 'sad' as const, version: '1.0.0' }; + const markdown = '---\ntitle: Payments SAD\ndescription: Decisions\n---\n# Content\n'; + + describe('resolveNarrativeEntry', () => { + it('resolves a valid unpublished narrative', () => { + const resolved = resolveNarrativeEntry('payments', identity, markdown); + + expect(resolved).toMatchObject({ + version: '1.0.0', + hubIdentityAssigned: false, + identity, + narrative: { + request: { name: 'Payments SAD', description: 'Decisions', documentMarkdown: markdown }, + }, + }); + expect(resolved.identity.calmHubDocumentId).toBeUndefined(); + }); + + it('resolves a valid published narrative', () => { + const resolved = resolveNarrativeEntry('payments', { + ...identity, + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', + }, markdown); + + expect(resolved.hubIdentityAssigned).toBe(true); + expect(resolved.identity.calmHubDocumentId).toBe(42); + expect(resolved.narrative.request.documentMarkdown).toBe(markdown); + }); + + it('rejects a missing manifest version', () => { + expect(() => resolveNarrativeEntry('payments', { + type: 'sad', namespace: 'finos', + }, markdown)).toThrow(/Narrative document 'payments' has no manifest version\./); + }); + + it.each([ + ['document ID only', { ...identity, calmHubDocumentId: 42 }], + ['Location only', { ...identity, calmHubId: '/stored-location' }], + ])('rejects incomplete Hub identity with %s', (_case, entry) => { + expect(() => resolveNarrativeEntry('payments', entry, markdown)).toThrow( + /Narrative document 'payments' has incomplete Hub identity\. Re-add the document to repair it\./ + ); + }); + + it.each([ + [{ ...identity, namespace: 'not_valid' }, /valid namespace/], + [{ ...identity, type: 'unsupported' }, /unsupported type/], + [{ ...identity, version: 'latest' }, /major.minor.patch/], + [{ ...identity, calmHubDocumentId: 0, calmHubId: '/stored-location' }, /positive integer/], + ])('rejects invalid identity data %#', (entry, message) => { + expect(() => resolveNarrativeEntry('payments', entry, markdown)).toThrow(message); + }); + + it('rejects malformed narrative Markdown', () => { + expect(() => resolveNarrativeEntry('payments', identity, '# No frontmatter')).toThrow( + /must contain non-empty YAML mapping frontmatter/ + ); + }); + }); + + it('uses frontmatter title and preserves CRLF Markdown', () => { + const markdown = '---\r\ntitle: Payments SAD\r\ndescription: Decisions\r\n---\r\n# Content\r\n'; + expect(parseNarrativeDocument(markdown, 'payments')).toEqual({ + request: { name: 'Payments SAD', description: 'Decisions', documentMarkdown: markdown }, + }); + }); + + it('publishes without an optional description and rejects malformed YAML', () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Content'; + expect(parseNarrativeDocument(markdown, 'payments').request).toEqual({ name: 'Payments SAD', documentMarkdown: markdown }); + expect(() => parseNarrativeDocument('---\ntitle: [\n---\n# Broken', 'broken')).toThrow(/malformed YAML/); + }); + + it.each([ + '# No frontmatter', + '---\n---\n# Empty mapping', + '---\n- one\n---\n# Array', + '---\ntitle: 42\n---\n# Invalid title', + '---\ntitle: Good\ndescription: 42\n---\n# Invalid description', + ])('rejects invalid frontmatter', (markdown) => { + expect(() => parseNarrativeDocument(markdown, 'bad')).toThrow(/Narrative document/); + }); + + it('validates identity and matching Location', () => { + validateNarrativeIdentity(identity, false); + expect(parseNarrativeDocumentLocation('/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', identity)).toBe(42); + expect(parseNarrativeDocumentLocation('http://localhost:8080/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', identity)).toBe(42); + expect(() => validateNarrativeIdentity({ ...identity, version: 'latest' }, false)).toThrow(/major.minor.patch/); + expect(() => parseNarrativeDocumentLocation('/api/calm/namespaces/other/documents/sad/42/versions/1.0.0', identity)).toThrow(/does not match/); + }); + + it.each(CALM_NARRATIVE_DOCUMENT_TYPES_LIST)('accepts the supported %s Location type', (type) => { + const narrativeIdentity = { ...identity, type }; + expect(parseNarrativeDocumentLocation( + `/api/calm/namespaces/finos/documents/${type}/42/versions/1.0.0`, + narrativeIdentity + )).toBe(42); + }); + + it.each([ + [{ ...identity, namespace: 'not_valid' }, false, /valid namespace/], + [{ ...identity, namespace: 42 }, false, /valid namespace/], + [{ ...identity, type: 'other' as never }, false, /unsupported/], + [{ ...identity, version: 1 }, false, /major.minor.patch/], + [{ ...identity, version: '01.0.0' }, false, /major.minor.patch/], + [{ ...identity, calmHubDocumentId: 0 }, true, /positive integer/], + ])('rejects invalid persisted identity %#', (candidate, requireId, message) => { + expect(() => validateNarrativeIdentity(candidate, requireId)).toThrow(message); + }); + + it('rejects malformed and mismatched persisted Locations', () => { + expect(() => parseNarrativeDocumentLocation('not-a-location', identity)).toThrow(/unexpected format/); + expect(() => parseNarrativeDocumentLocation('/api/calm/namespaces/finos/documents/sad/0/versions/1.0.0', identity)).toThrow(/invalid document id/); + expect(() => parseNarrativeDocumentLocation( + '/api/calm/namespaces/finos/documents/sad/43/versions/1.0.0', + { ...identity, calmHubDocumentId: 42 } + )).toThrow(/stored document id/); + expect(() => parseNarrativeDocumentLocation(null, identity)).toThrow(/unexpected format/); + expect(() => parseNarrativeDocumentLocation( + '/api/calm/namespaces/finos/documents/sad/42/versions/01.0.0', identity, false + )).toThrow(/unexpected format/); + expect(parseNarrativeDocumentLocation( + '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', + { ...identity, version: '1.1.0', calmHubDocumentId: 42 }, false + )).toBe(42); + }); + + it('validates persisted Locations and constructs canonical paths', () => { + const storedIdentity = { ...identity, calmHubDocumentId: 42 }; + expect(() => validateNarrativeDocumentLocation( + '/api/calm/namespaces/other/documents/sad/42/versions/1.0.0', storedIdentity + )).toThrow(/does not match/); + expect(constructNarrativeDocumentPath(storedIdentity)).toBe( + '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0' + ); + }); +}); diff --git a/cli/src/command-helpers/workspace/narrative-document.ts b/cli/src/command-helpers/workspace/narrative-document.ts new file mode 100644 index 000000000..5afd3dbfe --- /dev/null +++ b/cli/src/command-helpers/workspace/narrative-document.ts @@ -0,0 +1,200 @@ +import { parseYamlFrontMatterMapping, type NarrativeDocumentRequest } from '@finos/calm-shared'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST, isNarrativeDocumentType, type NarrativeDocumentType } from '@finos/calm-models/types'; + +const LOCATION_PATTERN = new RegExp( + `^/api/calm/namespaces/([^/]+)/documents/(${CALM_NARRATIVE_DOCUMENT_TYPES_LIST.join('|')})/(\\d+)/versions/` + + '((?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*))$' +); +const NAMESPACE_PATTERN = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/; +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + +export interface NarrativeDocumentIdentity { + namespace: string; + type: NarrativeDocumentType; + version: string; + calmHubDocumentId?: number; +} + +export interface ParsedNarrativeDocument { + request: NarrativeDocumentRequest; +} + +export interface NarrativeEntryInput { + type: unknown; + namespace?: unknown; + version?: unknown; + calmHubDocumentId?: unknown; + calmHubId?: unknown; +} + +export type ResolvedNarrativeEntry = { + version: string; + narrative: ParsedNarrativeDocument; +} & ( + | { + hubIdentityAssigned: false; + identity: NarrativeDocumentIdentity & { calmHubDocumentId?: undefined }; + } + | { + hubIdentityAssigned: true; + identity: NarrativeDocumentIdentity & { calmHubDocumentId: number }; + } +); + +export function resolveNarrativeEntry( + id: string, + entry: NarrativeEntryInput, + raw: string +): ResolvedNarrativeEntry { + if (!entry.version) { + throw new Error(`Narrative document '${id}' has no manifest version.`); + } + + const hubIdentityAssigned = entry.calmHubDocumentId !== undefined; + if (hubIdentityAssigned !== (entry.calmHubId !== undefined)) { + throw new Error(`Narrative document '${id}' has incomplete Hub identity. Re-add the document to repair it.`); + } + + const namespace = entry.namespace; + validateNarrativeNamespace(namespace, id); + const identity = { + namespace, + type: entry.type, + version: entry.version, + ...(hubIdentityAssigned ? { calmHubDocumentId: entry.calmHubDocumentId } : {}), + }; + const narrative = parseNarrativeDocument(raw, id); + validateNarrativeIdentity(identity, hubIdentityAssigned, id); + + if (hubIdentityAssigned) { + return { + version: identity.version, + identity: identity as NarrativeDocumentIdentity & { calmHubDocumentId: number }, + narrative, + hubIdentityAssigned: true, + }; + } + return { + version: identity.version, + identity: { + namespace: identity.namespace, + type: identity.type, + version: identity.version, + }, + narrative, + hubIdentityAssigned: false, + }; +} + +export function parseNarrativeDocument(markdown: string, label: string): ParsedNarrativeDocument { + let frontMatter: Record | null; + try { + frontMatter = parseYamlFrontMatterMapping(markdown); + } catch { + throw new Error(`Narrative document '${label}' has malformed YAML frontmatter.`); + } + if (!frontMatter || Object.keys(frontMatter).length === 0) { + throw new Error(`Narrative document '${label}' must contain non-empty YAML mapping frontmatter.`); + } + + const title = frontMatter.title; + if (typeof title !== 'string' || !title.trim()) { + throw new Error(`Narrative document '${label}' frontmatter must contain a non-empty string title.`); + } + + const description = frontMatter.description; + if (description !== undefined && typeof description !== 'string') { + throw new Error(`Narrative document '${label}' frontmatter description must be a string.`); + } + + return { + request: { + name: title.trim(), + ...(description === undefined ? {} : { description }), + documentMarkdown: markdown, + }, + }; +} + +export function validateNarrativeIdentity(identity: unknown, requireDocumentId: boolean, label?: string): asserts identity is NarrativeDocumentIdentity { + const prefix = label ? `Narrative document '${label}' ` : 'Narrative document '; + if (!identity || typeof identity !== 'object') { + throw new Error(`${prefix}identity must be an object.`); + } + const candidate = identity as Record; + if (typeof candidate.namespace !== 'string' || !NAMESPACE_PATTERN.test(candidate.namespace)) { + throw new Error(`${prefix}namespace must be a non-empty valid namespace.`); + } + if (!isNarrativeDocumentType(candidate.type)) { + throw new Error(`${prefix}has unsupported type '${String(candidate.type)}'.`); + } + if (typeof candidate.version !== 'string' || !SEMVER_PATTERN.test(candidate.version)) { + throw new Error(`${prefix}version '${String(candidate.version)}' must be major.minor.patch.`); + } + if (requireDocumentId && (!Number.isSafeInteger(candidate.calmHubDocumentId) || (candidate.calmHubDocumentId as number) <= 0)) { + throw new Error(`${prefix}calmHubDocumentId must be a positive integer.`); + } +} + +export function validateNarrativeNamespace(namespace: unknown, label?: string): asserts namespace is string { + const prefix = label ? `Narrative document '${label}' ` : 'Narrative document '; + if (typeof namespace !== 'string' || !NAMESPACE_PATTERN.test(namespace)) { + throw new Error(`${prefix}namespace must be a non-empty valid namespace.`); + } +} + +export function parseNarrativeDocumentLocation( + location: unknown, + identity: NarrativeDocumentIdentity, + requireIdentityVersion: boolean = true +): number { + if (typeof location !== 'string' || !location) { + throw new Error(`Narrative document Location '${String(location)}' has an unexpected format.`); + } + const path = extractLocationPath(location); + const match = LOCATION_PATTERN.exec(path); + if (!match) { + throw new Error(`Narrative document Location '${location}' has an unexpected format.`); + } + const [, namespace, type, idString, version] = match; + if (namespace !== identity.namespace || type !== identity.type || (requireIdentityVersion && version !== identity.version)) { + throw new Error(`Narrative document Location '${location}' does not match the requested identity.`); + } + const id = Number(idString); + if (!Number.isSafeInteger(id) || id <= 0) { + throw new Error(`Narrative document Location '${location}' has an invalid document id.`); + } + if (identity.calmHubDocumentId !== undefined && id !== identity.calmHubDocumentId) { + throw new Error(`Narrative document Location '${location}' does not match the stored document id.`); + } + return id; +} + +export function validateNarrativeDocumentLocation( + location: unknown, + identity: NarrativeDocumentIdentity, + requireIdentityVersion: boolean = true +): void { + parseNarrativeDocumentLocation(location, identity, requireIdentityVersion); +} + +export function constructNarrativeDocumentPath(identity: NarrativeDocumentIdentity): string { + validateNarrativeIdentity(identity, true); + return `/api/calm/namespaces/${identity.namespace}/documents/${identity.type}/${identity.calmHubDocumentId}/versions/${identity.version}`; +} + +function extractLocationPath(location: string): string { + if (location.startsWith('/')) { + return location; + } + + try { + const url = new URL(location); + if (!['http:', 'https:'].includes(url.protocol) || url.search || url.hash) { + throw new Error('invalid Location URL'); + } + return url.pathname; + } catch { + throw new Error(`Narrative document Location '${location}' has an unexpected format.`); + } +} diff --git a/cli/src/command-helpers/workspace/push.spec.ts b/cli/src/command-helpers/workspace/push.spec.ts index 6b22e0f0c..a6de7002c 100644 --- a/cli/src/command-helpers/workspace/push.spec.ts +++ b/cli/src/command-helpers/workspace/push.spec.ts @@ -1,17 +1,26 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { pushWorkspaceToHub } from './push'; import { loadManifest, saveManifest } from './bundle'; +import * as bundle from './bundle'; import { CalmHubClient, HubClientError } from '@finos/calm-shared'; import { mkdir, writeFile, rm } from 'fs/promises'; import path from 'path'; import { existsSync } from 'fs'; const makeClient = ( - overrides: Partial> = {} + overrides: Partial> = {} ): CalmHubClient => ({ getMappedResourceVersions: vi.fn(async () => []), createMappedResourceVersion: vi.fn(async () => '/calm/namespaces/com.example/architectures/my-arch/versions/1.0.0'), getMappedResourceByVersion: vi.fn(async () => ({})), + createNarrativeDocument: vi.fn(async () => '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0'), + createNarrativeDocumentVersion: vi.fn(async () => '/api/calm/namespaces/com.example/documents/sad/42/versions/1.1.0'), + getNarrativeDocumentIds: vi.fn(async () => []), + getNarrativeDocumentVersions: vi.fn(async () => []), + getNarrativeDocumentVersion: vi.fn(async () => ({ documentMarkdown: '' })), ...overrides, }) as unknown as CalmHubClient; @@ -30,7 +39,7 @@ const mappingId = (resource: string, version = '1.0.0', type = 'architectures', `${BASE}/calm/namespaces/${ns}/${type}/${resource}/versions/${version}`; describe('pushWorkspaceToHub', () => { - const testDir = path.join(__dirname, 'test-push'); + const testDir = path.resolve(__dirname, '../../../../sandbox/test-push'); const bundlePath = path.join(testDir, 'bundle'); const filesPath = path.join(bundlePath, 'files'); @@ -53,6 +62,17 @@ describe('pushWorkspaceToHub', () => { await mkdir(filesPath, { recursive: true }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function writeFreshNarrative(markdown: string): Promise { + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + } + it('resolves file path when entry.path is absolute', async () => { const absoluteFilePath = path.join(filesPath, 'doc-a.json'); await writeFile(absoluteFilePath, JSON.stringify(docA)); @@ -83,6 +103,368 @@ describe('pushWorkspaceToHub', () => { expect(client.getMappedResourceVersions).not.toHaveBeenCalled(); }); + it('creates a narrative document and stores its server identity', async () => { + const markdown = '---\ntitle: Payments SAD\ndescription: Decisions\n---\n# Payments\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + 'payments-sad': { path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + const client = makeClient(); + + await pushWorkspaceToHub(bundlePath, client); + + expect(client.createNarrativeDocument).toHaveBeenCalledWith('com.example', 'sad', { + name: 'Payments SAD', description: 'Decisions', documentMarkdown: markdown, + }); + expect(await loadManifest(bundlePath)).toMatchObject({ + 'payments-sad': { calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0' }, + }); + expect((await loadManifest(bundlePath))['payments-sad']).not.toHaveProperty('createRecovery'); + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + expect(client.createNarrativeDocument).toHaveBeenCalledOnce(); + }); + + it('saves recovery state before invoking narrative create', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const persistManifest = saveManifest; + let notifySaveStarted!: () => void; + const saveStarted = new Promise((resolve) => { notifySaveStarted = resolve; }); + let allowSave!: () => void; + const saveAllowed = new Promise((resolve) => { allowSave = resolve; }); + vi.spyOn(bundle, 'saveManifest').mockImplementationOnce(async (bundlePath, manifest) => { + notifySaveStarted(); + await saveAllowed; + await persistManifest(bundlePath, manifest); + }); + const location = '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0'; + const client = makeClient({ + createNarrativeDocument: vi.fn(async () => { + expect((await loadManifest(bundlePath)).payments).toEqual({ + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + createRecovery: { pending: true }, + }); + return location; + }), + }); + + const push = pushWorkspaceToHub(bundlePath, client); + await saveStarted; + try { + expect(client.createNarrativeDocument).not.toHaveBeenCalled(); + } finally { + allowSave(); + } + await push; + + expect(client.createNarrativeDocument).toHaveBeenCalledOnce(); + expect((await loadManifest(bundlePath)).payments).toMatchObject({ calmHubDocumentId: 42, calmHubId: location }); + expect((await loadManifest(bundlePath)).payments).not.toHaveProperty('createRecovery'); + }); + + it('does not POST when saving pre-create recovery state fails', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const originalManifest = await loadManifest(bundlePath); + const save = vi.spyOn(bundle, 'saveManifest').mockRejectedValueOnce(new Error('manifest write failed')); + const client = makeClient(); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/manifest write failed/); + + expect(save).toHaveBeenCalledOnce(); + expect(save).toHaveBeenCalledWith(bundlePath, { + payments: { + ...originalManifest.payments, + createRecovery: { pending: true }, + }, + }); + expect(client.createNarrativeDocument).not.toHaveBeenCalled(); + expect(await loadManifest(bundlePath)).toEqual(originalManifest); + }); + + it('keeps a status 0 create pending and never adopts another matching document on retry', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const firstClient = makeClient({ + createNarrativeDocument: vi.fn().mockRejectedValue( + new HubClientError(0, 'connection reset', 'POST /api/calm/namespaces/com.example/documents/sad') + ), + }); + + await expect(pushWorkspaceToHub(bundlePath, firstClient)).rejects.toThrow(/Hub error 0/); + + const pending = { + path: 'files/payments.md', type: 'sad' as const, namespace: 'com.example', version: '1.0.0', + createRecovery: { pending: true as const }, + }; + expect((await loadManifest(bundlePath)).payments).toEqual(pending); + expect(firstClient.createNarrativeDocument).toHaveBeenCalledOnce(); + + const retryClient = makeClient({ + getNarrativeDocumentIds: vi.fn().mockResolvedValue([77]), + getNarrativeDocumentVersion: vi.fn().mockResolvedValue({ documentMarkdown: markdown }), + }); + await expect(pushWorkspaceToHub(bundlePath, retryClient)).rejects.toThrow(/Explicit reconciliation is required/); + + expect(retryClient.createNarrativeDocument).not.toHaveBeenCalled(); + expect(retryClient.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(retryClient.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + expect((await loadManifest(bundlePath)).payments).toEqual(pending); + }); + + it.each([400, 401, 403, 404])('restores unpublished state after definite create rejection %i and permits retry', async (status) => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const unpublished = (await loadManifest(bundlePath)).payments; + const rejectedClient = makeClient({ + createNarrativeDocument: vi.fn().mockRejectedValue( + new HubClientError(status, 'rejected', 'POST /api/calm/namespaces/com.example/documents/sad') + ), + }); + + await expect(pushWorkspaceToHub(bundlePath, rejectedClient)).rejects.toThrow(`Hub error ${status}`); + + expect((await loadManifest(bundlePath)).payments).toEqual(unpublished); + expect(rejectedClient.createNarrativeDocument).toHaveBeenCalledOnce(); + + const retryClient = makeClient(); + await pushWorkspaceToHub(bundlePath, retryClient); + + expect(retryClient.createNarrativeDocument).toHaveBeenCalledOnce(); + expect((await loadManifest(bundlePath)).payments).toMatchObject({ + calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }); + }); + + it('preserves an absolute valid Location without recovery', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const location = 'https://hub.example.com/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0'; + const client = makeClient({ createNarrativeDocument: vi.fn().mockResolvedValue(location) }); + + await pushWorkspaceToHub(bundlePath, client); + + expect((await loadManifest(bundlePath)).payments).toMatchObject({ calmHubDocumentId: 42, calmHubId: location }); + expect((await loadManifest(bundlePath)).payments).not.toHaveProperty('createRecovery'); + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + }); + + it('preserves independent identities for multiple narratives', async () => { + const payments = '---\ntitle: Payments SAD\n---\n# Payments'; + const orders = '---\ntitle: Orders Knowledge\n---\n# Orders'; + await writeFile(path.join(filesPath, 'payments.md'), payments); + await writeFile(path.join(filesPath, 'orders.md'), orders); + await saveManifest(bundlePath, { + payments: { path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + orders: { path: 'files/orders.md', type: 'knowledge', namespace: 'com.example', version: '1.0.0' }, + }); + const createNarrativeDocument = vi.fn().mockImplementation(async (_namespace, type) => + `/api/calm/namespaces/com.example/documents/${type}/${type === 'sad' ? 3 : 4}/versions/1.0.0` + ); + const client = makeClient({ createNarrativeDocument }); + + await pushWorkspaceToHub(bundlePath, client); + + expect(await loadManifest(bundlePath)).toMatchObject({ + payments: { calmHubDocumentId: 3 }, + orders: { calmHubDocumentId: 4 }, + }); + expect(createNarrativeDocument).toHaveBeenCalledTimes(2); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + }); + + it.each(['/unexpected', undefined])('retains pending state for unusable Location %s without automatic recovery', async (location) => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const client = makeClient({ + createNarrativeDocument: vi.fn().mockResolvedValue(location), + getNarrativeDocumentIds: vi.fn().mockResolvedValue([77]), + getNarrativeDocumentVersion: vi.fn().mockResolvedValue({ documentMarkdown: markdown }), + }); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/Explicit reconciliation is required/); + + const pending = { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + createRecovery: { pending: true }, + }; + expect((await loadManifest(bundlePath)).payments).toEqual(pending); + expect(client.createNarrativeDocument).toHaveBeenCalledOnce(); + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/--calm-hub-document-id /); + + expect(client.createNarrativeDocument).toHaveBeenCalledOnce(); + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + expect((await loadManifest(bundlePath)).payments).toEqual(pending); + }); + + it.each([ + null, + {}, + { pending: false }, + { pending: true, extra: true }, + ])('rejects malformed persisted create recovery before parsing or posting: %j', async (createRecovery) => { + const markdown = '# no frontmatter'; + await writeFreshNarrative(markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', createRecovery, + } as never, + }); + const client = makeClient(); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/createRecovery/); + + expect(client.createNarrativeDocument).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + }); + + it.each([500, 413])('retains pending state after ambiguous Hub status %i and does not POST on retry', async (status) => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments'; + await writeFreshNarrative(markdown); + const client = makeClient({ + createNarrativeDocument: vi.fn().mockRejectedValue( + new HubClientError(status, 'unavailable', 'POST /api/calm/namespaces/com.example/documents/sad') + ), + }); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(`Hub error ${status}`); + + expect(client.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(client.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + expect(client.createNarrativeDocument).toHaveBeenCalledOnce(); + expect((await loadManifest(bundlePath)).payments).not.toHaveProperty('calmHubDocumentId'); + expect((await loadManifest(bundlePath)).payments).toHaveProperty('createRecovery', { pending: true }); + + const retryClient = makeClient(); + await expect(pushWorkspaceToHub(bundlePath, retryClient)).rejects.toThrow(/Explicit reconciliation is required/); + + expect(retryClient.createNarrativeDocument).not.toHaveBeenCalled(); + expect(retryClient.getNarrativeDocumentIds).not.toHaveBeenCalled(); + expect(retryClient.getNarrativeDocumentVersion).not.toHaveBeenCalled(); + }); + + it('fails the completed push when a narrative document is invalid', async () => { + await writeFile(path.join(filesPath, 'bad.md'), '# no frontmatter'); + await saveManifest(bundlePath, { + bad: { path: 'files/bad.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + await expect(pushWorkspaceToHub(bundlePath, makeClient())).rejects.toThrow(/narrative document/); + expect((await loadManifest(bundlePath)).bad.calmHubDocumentId).toBeUndefined(); + }); + + it('creates a later narrative version and updates its location', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.1.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ getNarrativeDocumentVersions: vi.fn().mockResolvedValue(['1.0.0']) }); + + await pushWorkspaceToHub(bundlePath, client); + + expect(client.createNarrativeDocumentVersion).toHaveBeenCalledWith('com.example', 'sad', 42, '1.1.0', expect.objectContaining({ documentMarkdown: markdown })); + expect((await loadManifest(bundlePath)).payments.calmHubId).toContain('/1.1.0'); + }); + + it('strictly detects changed Markdown at an existing narrative version', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Changed\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ + getNarrativeDocumentVersions: vi.fn().mockResolvedValue(['1.0.0']), + getNarrativeDocumentVersion: vi.fn().mockResolvedValue({ documentMarkdown: markdown.replace('Changed', 'Published') }), + }); + + await expect(pushWorkspaceToHub(bundlePath, client, { failIfModified: true })).rejects.toThrow(/payments@1.0.0/); + }); + + it('idempotently skips an existing narrative version and accepts an exact strict comparison', async () => { + const markdown = '---\ntitle: Payments SAD\n---\n# Payments\n'; + await writeFile(path.join(filesPath, 'payments.md'), markdown); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 'com.example', version: '1.0.0', + calmHubDocumentId: 42, calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient({ + getNarrativeDocumentVersions: vi.fn().mockResolvedValue(['1.0.0']), + getNarrativeDocumentVersion: vi.fn().mockResolvedValue({ documentMarkdown: markdown }), + }); + + await pushWorkspaceToHub(bundlePath, client); + await pushWorkspaceToHub(bundlePath, client, { failIfModified: true }); + + expect(client.createNarrativeDocumentVersion).not.toHaveBeenCalled(); + }); + + it('fails narrative publish for missing source files and incomplete Hub identity', async () => { + await saveManifest(bundlePath, { + missing: { path: 'files/missing.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + partial: { path: 'files/partial.md', type: 'sad', namespace: 'com.example', version: '1.0.0', calmHubId: '/partial' }, + }); + await writeFile(path.join(filesPath, 'partial.md'), '---\ntitle: Partial\n---\n# Partial'); + await expect(pushWorkspaceToHub(bundlePath, makeClient())).rejects.toThrow(/incomplete Hub identity/); + }); + + it('rejects malformed persisted narrative identity before calling Hub', async () => { + await writeFile(path.join(filesPath, 'payments.md'), '---\ntitle: Payments SAD\n---\n# Payments'); + await saveManifest(bundlePath, { + payments: { + path: 'files/payments.md', type: 'sad', namespace: 42 as unknown as string, version: '1.1.0', calmHubDocumentId: 42, + calmHubId: '/api/calm/namespaces/com.example/documents/sad/42/versions/1.0.0', + }, + }); + const client = makeClient(); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/valid namespace/); + expect(client.getNarrativeDocumentVersions).not.toHaveBeenCalled(); + }); + + it('rejects a missing narrative namespace without Hub calls or manifest mutation', async () => { + await writeFile(path.join(filesPath, 'payments.md'), '---\ntitle: Payments SAD\n---\n# Payments'); + const entry = { path: 'files/payments.md', type: 'sad' as const, version: '1.0.0' }; + await saveManifest(bundlePath, { payments: entry }); + const client = makeClient(); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/valid namespace/); + expect(client.createNarrativeDocument).not.toHaveBeenCalled(); + expect(await loadManifest(bundlePath)).toEqual({ payments: entry }); + }); + + it('rejects narrative manifests with no version or a non-initial unassigned version', async () => { + await writeFile(path.join(filesPath, 'missing.md'), '---\ntitle: Missing\n---\n# Missing'); + await writeFile(path.join(filesPath, 'ahead.md'), '---\ntitle: Ahead\n---\n# Ahead'); + await saveManifest(bundlePath, { + missing: { path: 'files/missing.md', type: 'sad', namespace: 'com.example' }, + ahead: { path: 'files/ahead.md', type: 'sad', namespace: 'com.example', version: '1.1.0' }, + }); + + await expect(pushWorkspaceToHub(bundlePath, makeClient())).rejects.toThrow(/narrative document/); + }); + + it('fails narrative publish when a tracked path cannot be read', async () => { + await saveManifest(bundlePath, { + unreadable: { path: 'files', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + await expect(pushWorkspaceToHub(bundlePath, makeClient())).rejects.toThrow(/file could not be read/); + }); + it('skips entries whose file is invalid JSON', async () => { await writeFile(path.join(filesPath, 'bad.json'), 'not json {{{'); await saveManifest(bundlePath, { @@ -190,11 +572,14 @@ describe('pushWorkspaceToHub', () => { .mockResolvedValueOnce(mappingId('doc-b')), }); - await expect(pushWorkspaceToHub(bundlePath, client)).resolves.not.toThrow(); + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow( + /mapping document\(s\) failed \(doc-a: create failed\)/ + ); expect(client.createMappedResourceVersion).toHaveBeenCalledTimes(2); + expect((await loadManifest(bundlePath))['doc-b'].calmHubId).toBe(mappingId('doc-b')); }); - it('logs error and continues when fetching existing versions fails', async () => { + it('fails after processing later entries when fetching existing versions fails', async () => { await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify(docA)); await writeFile(path.join(filesPath, 'doc-b.json'), JSON.stringify(docB)); await saveManifest(bundlePath, { @@ -205,14 +590,71 @@ describe('pushWorkspaceToHub', () => { getMappedResourceVersions: vi.fn() .mockRejectedValueOnce(new HubClientError(500, 'Internal Server Error', 'GET ...')) .mockResolvedValueOnce([]), + createMappedResourceVersion: vi.fn().mockResolvedValue(mappingId('doc-b')), }); - await expect(pushWorkspaceToHub(bundlePath, client)).resolves.not.toThrow(); + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/doc-a: .*Internal Server Error/); expect(client.createMappedResourceVersion).toHaveBeenCalledTimes(1); expect(client.createMappedResourceVersion).toHaveBeenCalledWith( expect.objectContaining({ mapping: 'doc-b' }), JSON.stringify(docB) ); + expect((await loadManifest(bundlePath))['doc-b'].calmHubId).toBe(mappingId('doc-b')); + }); + + it('reports multiple mapping failures without losing document details', async () => { + await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify(docA)); + await writeFile(path.join(filesPath, 'doc-b.json'), JSON.stringify(docB)); + await saveManifest(bundlePath, { + 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' }, + 'doc-b': { path: 'files/doc-b.json', type: 'architecture', namespace: 'com.example' }, + }); + const client = makeClient({ + createMappedResourceVersion: vi.fn() + .mockRejectedValueOnce(new Error('first create failed')) + .mockRejectedValueOnce(new Error('second create failed')), + }); + + const push = pushWorkspaceToHub(bundlePath, client); + await expect(push).rejects.toThrow(/doc-a: first create failed/); + await expect(push).rejects.toThrow(/doc-b: second create failed/); + expect(client.createMappedResourceVersion).toHaveBeenCalledTimes(2); + }); + + it('reports mapping and narrative failures together', async () => { + await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify(docA)); + await writeFile(path.join(filesPath, 'bad.md'), '# no frontmatter'); + await saveManifest(bundlePath, { + 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' }, + bad: { path: 'files/bad.md', type: 'sad', namespace: 'com.example', version: '1.0.0' }, + }); + const client = makeClient({ + createMappedResourceVersion: vi.fn().mockRejectedValue(new Error('mapping unavailable')), + }); + + const push = pushWorkspaceToHub(bundlePath, client); + await expect(push).rejects.toThrow(/mapping document\(s\) failed \(doc-a: mapping unavailable\)/); + await expect(push).rejects.toThrow(/narrative document\(s\) failed \(bad:/); + }); + + it('reports mapping failures and modified-version conflicts together', async () => { + const changedDocB = { ...docB, extra: 'edited' }; + await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify(docA)); + await writeFile(path.join(filesPath, 'doc-b.json'), JSON.stringify(changedDocB)); + await saveManifest(bundlePath, { + 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' }, + 'doc-b': { path: 'files/doc-b.json', type: 'architecture', namespace: 'com.example' }, + }); + const client = makeClient({ + getMappedResourceVersions: vi.fn(async (_namespace: string, resource: string) => + resource === 'doc-b' ? ['1.0.0'] : []), + getMappedResourceByVersion: vi.fn().mockResolvedValue(docB), + createMappedResourceVersion: vi.fn().mockRejectedValue(new Error('create unavailable')), + }); + + const push = pushWorkspaceToHub(bundlePath, client, { failIfModified: true }); + await expect(push).rejects.toThrow(/doc-b@1\.0\.0/); + await expect(push).rejects.toThrow(/mapping document\(s\) failed \(doc-a: create unavailable\)/); }); describe('failIfModified (strict merge-time push)', () => { @@ -246,21 +688,26 @@ describe('pushWorkspaceToHub', () => { expect(client.createMappedResourceVersion).not.toHaveBeenCalled(); }); - it('skips and does not fail when fetching the published version to compare fails', async () => { + it('fails after processing later entries when fetching the published version to compare fails', async () => { await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify({ ...docA, extra: 'edited' })); + await writeFile(path.join(filesPath, 'doc-b.json'), JSON.stringify(docB)); await saveManifest(bundlePath, { - 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' } + 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' }, + 'doc-b': { path: 'files/doc-b.json', type: 'architecture', namespace: 'com.example' }, }); const client = makeClient({ - getMappedResourceVersions: vi.fn().mockResolvedValue(['1.0.0']), + getMappedResourceVersions: vi.fn(async (_namespace: string, resource: string) => + resource === 'doc-a' ? ['1.0.0'] : []), getMappedResourceByVersion: vi.fn().mockRejectedValue(new Error('boom')), + createMappedResourceVersion: vi.fn().mockResolvedValue(mappingId('doc-b')), }); - await expect(pushWorkspaceToHub(bundlePath, client, { failIfModified: true })).resolves.not.toThrow(); - expect(client.createMappedResourceVersion).not.toHaveBeenCalled(); + await expect(pushWorkspaceToHub(bundlePath, client, { failIfModified: true })).rejects.toThrow(/doc-a: boom/); + expect(client.createMappedResourceVersion).toHaveBeenCalledOnce(); + expect((await loadManifest(bundlePath))['doc-b'].calmHubId).toBe(mappingId('doc-b')); }); - it('skips when the compare fetch rejects with a non-Error value', async () => { + it('reports a non-Error comparison failure', async () => { await writeFile(path.join(filesPath, 'doc-a.json'), JSON.stringify({ ...docA, extra: 'edited' })); await saveManifest(bundlePath, { 'doc-a': { path: 'files/doc-a.json', type: 'architecture', namespace: 'com.example' } @@ -270,7 +717,7 @@ describe('pushWorkspaceToHub', () => { getMappedResourceByVersion: vi.fn().mockRejectedValue('boom-string'), }); - await expect(pushWorkspaceToHub(bundlePath, client, { failIfModified: true })).resolves.not.toThrow(); + await expect(pushWorkspaceToHub(bundlePath, client, { failIfModified: true })).rejects.toThrow(/doc-a: boom-string/); expect(client.createMappedResourceVersion).not.toHaveBeenCalled(); }); diff --git a/cli/src/command-helpers/workspace/push.ts b/cli/src/command-helpers/workspace/push.ts index a18a045b8..85c41f0cd 100644 --- a/cli/src/command-helpers/workspace/push.ts +++ b/cli/src/command-helpers/workspace/push.ts @@ -1,10 +1,37 @@ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; -import { loadManifest, saveManifest, resolveFilePath } from './bundle'; -import { CalmHubClient, DocumentMetadata, extractDocumentMetadata, initLogger, Logger } from '@finos/calm-shared'; +import { + loadManifest, + saveManifest, + resolveFilePath, + type NarrativeCreateRecovery, + type NarrativeWorkspaceManifestEntry, + type PublishedNarrativeWorkspaceManifestEntry, + type MappingWorkspaceManifestEntry, + type WorkspaceManifest, +} from './bundle'; +import { + CalmHubClient, + DocumentMetadata, + HubClientError, + extractDocumentMetadata, + initLogger, + Logger, +} from '@finos/calm-shared'; import { canonicalEqual } from './bump'; +import { + parseNarrativeDocumentLocation, + resolveNarrativeEntry, + validateNarrativeDocumentLocation, +} from './narrative-document'; +import { + dispatchWorkspaceManifestEntry, + resolveWorkspaceManifestEntry, + type WorkspaceManifestEntryOperations, +} from './document-kind'; const logger: Logger = initLogger(false, 'workspace'); +const DEFINITE_CREATE_REJECTION_STATUSES = new Set([400, 401, 403, 404]); export interface PushOptions { /** @@ -17,6 +44,23 @@ export interface PushOptions { failIfModified?: boolean; } +interface PushEntryContext { + bundlePath: string; + client: CalmHubClient; + conflicts: string[]; + failIfModified: boolean; + id: string; + manifest: WorkspaceManifest; + mappingFailures: string[]; + narrativeFailures: string[]; + raw: string; +} + +const PUSH_ENTRY_OPERATIONS = { + mapping: pushMappingEntry, + narrative: pushNarrativeEntry, +} satisfies WorkspaceManifestEntryOperations, [PushEntryContext]>; + export async function pushWorkspaceToHub( bundlePath: string, client: CalmHubClient, @@ -32,12 +76,16 @@ export async function pushWorkspaceToHub( } const conflicts: string[] = []; + const mappingFailures: string[] = []; + const narrativeFailures: string[] = []; for (const [id, entry] of entries) { + const document = resolveWorkspaceManifestEntry(entry); const filePath = resolveFilePath(bundlePath, entry.path); if (!existsSync(filePath)) { logger.warn(`File not found for id '${id}': ${filePath}`); + if (document.handler.unreadableFile === 'fail') narrativeFailures.push(`${id}: file not found`); continue; } @@ -46,77 +94,249 @@ export async function pushWorkspaceToHub( raw = await readFile(filePath, 'utf8'); } catch (e) { logger.warn(`Failed to read file for id '${id}': ${e instanceof Error ? e.message : String(e)}`); + if (document.handler.unreadableFile === 'fail') narrativeFailures.push(`${id}: file could not be read`); continue; } - // The mapping API addresses resources by (namespace, type, mappingId, version), - // all encoded in the document's $id. Documents without a well-formed mapping $id - // (or whose type has no ResourceType, e.g. flow/adr) cannot be pushed and are skipped. - let metadata: DocumentMetadata; - try { - metadata = extractDocumentMetadata(raw); - } catch (e) { - logger.warn( - `Skipping '${id}': not mappable to CalmHub. Documents must have a '$id' of the form ` + - '$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION ' + - `(${e instanceof Error ? e.message : String(e)})` + await dispatchWorkspaceManifestEntry(document, PUSH_ENTRY_OPERATIONS, { + bundlePath, + client, + conflicts, + failIfModified, + id, + manifest, + mappingFailures, + narrativeFailures, + raw, + }); + } + + if (conflicts.length > 0 || mappingFailures.length > 0 || narrativeFailures.length > 0) { + const summaries: string[] = []; + if (conflicts.length > 0) { + summaries.push( + `${conflicts.length} modified document(s) already exist in CalmHub at their declared version ` + + `(${conflicts.join(', ')}). Run \`calm workspace bump\` to create new versions for them.` ); - continue; } - - const { namespace, type: resourceType, mapping: mappingId, version } = metadata; - if (!namespace) { - logger.warn(`Skipping '${id}': document $id has no namespace.`); - continue; + if (mappingFailures.length > 0) { + summaries.push(`${mappingFailures.length} mapping document(s) failed (${mappingFailures.join('; ')})`); + } + if (narrativeFailures.length > 0) { + summaries.push(`${narrativeFailures.length} narrative document(s) failed (${narrativeFailures.join('; ')})`); } + throw new Error( + `Push failed: ${summaries.join(' ')}` + ); + } +} - let existingVersions: string[]; - try { - existingVersions = await client.getMappedResourceVersions(namespace, mappingId, resourceType); - } catch (e) { - logger.error(`Failed to fetch existing versions for '${id}' from CalmHub: ${e instanceof Error ? e.message : String(e)}`); - continue; +async function pushNarrativeEntry( + entry: NarrativeWorkspaceManifestEntry, + context: PushEntryContext +): Promise { + const { bundlePath, client, conflicts, failIfModified, id, manifest, narrativeFailures, raw } = context; + try { + const createRecovery = getCreateRecovery(entry); + if (createRecovery !== undefined) { + throw new Error(createReconciliationMessage(id, entry)); } + const resolved = resolveNarrativeEntry(id, entry, raw); + const { version, narrative } = resolved; - if (existingVersions.includes(version)) { - if (!failIfModified) { - logger.info(`No changes for '${id}' - version ${version} already exists, skipping`); - continue; + if (!resolved.hubIdentityAssigned) { + const { identity } = resolved; + if (version !== '1.0.0') { + throw new Error('A narrative document without calmHubDocumentId must use version 1.0.0.'); } - // Strict mode: an existing version is only a conflict if the on-disk content differs - // from what is already published. Unchanged content is still skipped. - let remote: object; + manifest[id] = { + path: entry.path, + type: entry.type, + ...(entry.namespace === undefined ? {} : { namespace: entry.namespace }), + version, + createRecovery: { pending: true }, + }; + // Persist recovery before POST because a transport failure can hide a successful create. + await saveManifest(bundlePath, manifest); + let location: string | undefined; try { - remote = await client.getMappedResourceByVersion(namespace, mappingId, version, resourceType); + location = await client.createNarrativeDocument(identity.namespace, identity.type, narrative.request); } catch (e) { - logger.error(`Failed to fetch '${id}' @ ${version} from CalmHub to compare: ${e instanceof Error ? e.message : String(e)}`); - continue; + if (isDefiniteCreateRejection(e)) { + manifest[id] = entry; + await saveManifest(bundlePath, manifest); + } + throw e; } - if (canonicalEqual(JSON.parse(raw), remote)) { - logger.info(`No changes for '${id}' - version ${version} already exists and is unchanged, skipping`); - } else { - logger.error(`'${id}' version ${version} already exists in CalmHub but differs on disk. Bump it before pushing.`); - conflicts.push(`${id}@${version}`); + if (location === undefined) { + throw new Error(createReconciliationMessage(id, entry)); } - continue; + + let documentId: number; + try { + documentId = parseNarrativeDocumentLocation(location, identity); + } catch { + throw new Error(createReconciliationMessage(id, entry)); + } + + manifest[id] = publishNarrativeEntry(entry, documentId, location); + await saveManifest(bundlePath, manifest); + logger.info(`Pushed '${id}' version ${version} -> ${location}`); + return; } - try { - const calmHubId = await client.createMappedResourceVersion(metadata, raw); - manifest[id] = { ...entry, calmHubId }; + const { identity } = resolved; + validateNarrativeDocumentLocation(entry.calmHubId, identity, false); + const versions = await client.getNarrativeDocumentVersions( + identity.namespace, identity.type, identity.calmHubDocumentId + ); + if (!versions.includes(version)) { + const location = await client.createNarrativeDocumentVersion( + identity.namespace, identity.type, identity.calmHubDocumentId, version, narrative.request + ); + validateNarrativeDocumentLocation(location, identity); + manifest[id] = publishNarrativeEntry(entry, identity.calmHubDocumentId, location); await saveManifest(bundlePath, manifest); - logger.info(`Pushed '${id}' version ${version} -> ${calmHubId}`); - } catch (e) { - logger.error(`Failed to push '${id}': ${e instanceof Error ? e.message : String(e)}`); + logger.info(`Pushed '${id}' version ${version} -> ${location}`); + return; + } + + if (!failIfModified) { + logger.info(`No changes for '${id}' - version ${version} already exists, skipping`); + return; + } + const remote = await client.getNarrativeDocumentVersion( + identity.namespace, identity.type, identity.calmHubDocumentId, version + ); + if (remote.documentMarkdown !== raw) { + logger.error(`'${id}' version ${version} already exists in CalmHub but differs on disk. Bump it before pushing.`); + conflicts.push(`${id}@${version}`); + } else { + logger.info(`No changes for '${id}' - version ${version} already exists and is unchanged, skipping`); } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + logger.error(`Failed to push narrative document '${id}': ${message}`); + narrativeFailures.push(`${id}: ${message}`); } +} - if (conflicts.length > 0) { - throw new Error( - `Push failed: ${conflicts.length} modified document(s) already exist in CalmHub at their declared ` + - `version (${conflicts.join(', ')}). Run \`calm workspace bump\` to create new versions for them.` +async function pushMappingEntry( + entry: MappingWorkspaceManifestEntry, + context: PushEntryContext +): Promise { + const { bundlePath, client, conflicts, failIfModified, id, manifest, mappingFailures, raw } = context; + // The mapping API addresses resources by (namespace, type, mappingId, version), + // all encoded in the document's $id. Documents without a well-formed mapping $id + // (or whose type has no ResourceType, e.g. flow/adr) cannot be pushed and are skipped. + let metadata: DocumentMetadata; + try { + metadata = extractDocumentMetadata(raw); + } catch (e) { + logger.warn( + `Skipping '${id}': not mappable to CalmHub. Documents must have a '$id' of the form ` + + '$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION ' + + `(${e instanceof Error ? e.message : String(e)})` ); + return; + } + + const { namespace, type: resourceType, mapping: mappingId, version } = metadata; + if (!namespace) { + logger.warn(`Skipping '${id}': document $id has no namespace.`); + return; + } + + let existingVersions: string[]; + try { + existingVersions = await client.getMappedResourceVersions(namespace, mappingId, resourceType); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + logger.error(`Failed to fetch existing versions for '${id}' from CalmHub: ${message}`); + mappingFailures.push(`${id}: ${message}`); + return; + } + + if (existingVersions.includes(version)) { + if (!failIfModified) { + logger.info(`No changes for '${id}' - version ${version} already exists, skipping`); + return; + } + + // Strict mode: an existing version is only a conflict if the on-disk content differs + // from what is already published. Unchanged content is still skipped. + let remote: object; + try { + remote = await client.getMappedResourceByVersion(namespace, mappingId, version, resourceType); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + logger.error(`Failed to fetch '${id}' @ ${version} from CalmHub to compare: ${message}`); + mappingFailures.push(`${id}: ${message}`); + return; + } + + if (canonicalEqual(JSON.parse(raw), remote)) { + logger.info(`No changes for '${id}' - version ${version} already exists and is unchanged, skipping`); + } else { + logger.error(`'${id}' version ${version} already exists in CalmHub but differs on disk. Bump it before pushing.`); + conflicts.push(`${id}@${version}`); + } + return; + } + + try { + const calmHubId = await client.createMappedResourceVersion(metadata, raw); + manifest[id] = { ...entry, calmHubId }; + await saveManifest(bundlePath, manifest); + logger.info(`Pushed '${id}' version ${version} -> ${calmHubId}`); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + logger.error(`Failed to push '${id}': ${message}`); + mappingFailures.push(`${id}: ${message}`); + } +} + +function getCreateRecovery(entry: NarrativeWorkspaceManifestEntry): NarrativeCreateRecovery | undefined { + const createRecovery = (entry as unknown as Record).createRecovery; + if (createRecovery === undefined) return undefined; + + if (entry.calmHubDocumentId !== undefined || entry.calmHubId !== undefined) { + throw new Error('Narrative document cannot be both published and pending create recovery.'); } + if (!createRecovery || typeof createRecovery !== 'object' || Array.isArray(createRecovery)) { + throw new Error('Narrative document createRecovery must be a pending-create marker.'); + } + if ((createRecovery as Record).pending !== true || Object.keys(createRecovery).length !== 1) { + throw new Error('Narrative document createRecovery must contain only pending: true.'); + } + return { pending: true }; +} + +function isDefiniteCreateRejection(error: unknown): boolean { + return error instanceof HubClientError && DEFINITE_CREATE_REJECTION_STATUSES.has(error.status); +} + +function createReconciliationMessage(id: string, entry: NarrativeWorkspaceManifestEntry): string { + return `Narrative document '${id}' has a pending create with no authoritative CalmHub identity. ` + + 'Explicit reconciliation is required. Confirm the CalmHub document ID, then run ' + + `\`calm workspace add --id ${id} --type ${entry.type} --namespace ${entry.namespace ?? ''} ` + + `--calm-hub-document-id --ver ${entry.version}\` ` + + '(add `--calm-hub-url ` if it is not configured).'; +} + +function publishNarrativeEntry( + entry: NarrativeWorkspaceManifestEntry, + documentId: number, + location: string +): PublishedNarrativeWorkspaceManifestEntry { + return { + path: entry.path, + type: entry.type, + ...(entry.namespace === undefined ? {} : { namespace: entry.namespace }), + version: entry.version, + calmHubDocumentId: documentId, + calmHubId: location, + }; } diff --git a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts index da1b6b4ce..2ebc447d3 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts @@ -8,6 +8,7 @@ import { } from './ref-rewrite'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; +import type { WorkspaceManifest } from './bundle'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const loadJson = async (p: string): Promise => JSON.parse(await readFile(p, 'utf8')); @@ -152,6 +153,32 @@ describe('ref-rewrite orchestrators', () => { expect(b.$schema.const).toBe(idAt('a', '1.1.0')); }); + it('excludes narrative entries from JSON reference rules and rewrites', async () => { + await write('a.json', { $id: idAt('a', '1.1.0'), title: 'A' }); + const narrativePath = path.join(bundlePath, 'files', 'decision.md'); + const narrativeContent = JSON.stringify({ + $id: idAt('decision', '1.0.0'), + $ref: idAt('a', '1.0.0'), + }); + await writeFile(narrativePath, narrativeContent, 'utf8'); + const manifest: WorkspaceManifest = { + 'a': { path: 'files/a.json', type: 'architecture' }, + 'decision': { + path: 'files/decision.md', + type: 'sad', + namespace: 'com.example', + version: '1.0.0', + }, + }; + + const rules = await buildRefRulesFromDiskIds(manifest, bundlePath); + const results = await syncReferences(bundlePath, manifest, rules); + + expect(rules.map(rule => rule.bareId)).toEqual(['a']); + expect(results.map(result => result.docId)).toEqual(['a']); + expect(await readFile(narrativePath, 'utf8')).toBe(narrativeContent); + }); + it('skips missing files and unparseable JSON when building rules and syncing', async () => { await write('a.json', { $id: idAt('a', '1.1.0'), title: 'A' }); await writeFile(path.join(bundlePath, 'files', 'bad.json'), 'not json {{{', 'utf8'); diff --git a/cli/src/command-helpers/workspace/ref-rewrite.ts b/cli/src/command-helpers/workspace/ref-rewrite.ts index eef4122b3..e249c1554 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.ts @@ -2,6 +2,7 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { REFERENCE_PROPERTIES, WorkspaceManifest, resolveFilePath } from './bundle'; import { initLogger, Logger } from '@finos/calm-shared'; +import { getJsonReferenceWorkspaceManifest } from './document-kind'; const logger: Logger = initLogger(false, 'workspace'); @@ -157,17 +158,18 @@ export function replaceRefsInObject( } /** - * Build rewrite rules from the *current on-disk* `$id` of every tracked document. Any reference to - * a tracked document (bare id, stale versioned path, or full URL) is mapped to that document's - * current `$id`. Documents without a usable `$id` are skipped as targets (they can still contain - * references that get rewritten). + * Build rewrite rules from the *current on-disk* `$id` of each tracked JSON document. Any reference + * to a tracked JSON document (bare id, stale versioned path, or full URL) is mapped to that + * document's current `$id`. Documents without a usable `$id` are skipped as targets (they can still + * contain references that get rewritten). Non-JSON handlers are excluded by document-kind policy. */ export async function buildRefRulesFromDiskIds( manifest: WorkspaceManifest, bundlePath: string ): Promise { const rules: RefRule[] = []; - for (const [id, entry] of Object.entries(manifest)) { + const jsonManifest = getJsonReferenceWorkspaceManifest(manifest); + for (const [id, entry] of Object.entries(jsonManifest)) { const filePath = resolveFilePath(bundlePath, entry.path); if (!existsSync(filePath)) continue; try { @@ -189,9 +191,9 @@ export async function buildRefRulesFromDiskIds( } /** - * Rewrite references across all tracked documents according to the given rules, writing back any - * file that changed. Idempotent: a second run finds references already at their target and is a - * no-op. + * Rewrite references across tracked JSON documents according to the given rules, writing back any + * file that changed. Non-JSON handlers are excluded by document-kind policy. Idempotent: a second + * run finds references already at their target and is a no-op. */ export async function syncReferences( bundlePath: string, @@ -200,7 +202,8 @@ export async function syncReferences( ): Promise { const results: RefUpdateResult[] = []; - for (const [id, entry] of Object.entries(manifest)) { + const jsonManifest = getJsonReferenceWorkspaceManifest(manifest); + for (const [id, entry] of Object.entries(jsonManifest)) { const filePath = resolveFilePath(bundlePath, entry.path); if (!existsSync(filePath)) { logger.warn(`File not found for '${id}': ${filePath}`); diff --git a/shared/src/document-loader/workspace-document-kind.spec.ts b/shared/src/document-loader/workspace-document-kind.spec.ts new file mode 100644 index 000000000..701f8473e --- /dev/null +++ b/shared/src/document-loader/workspace-document-kind.spec.ts @@ -0,0 +1,30 @@ +import { + CALM_DOCUMENT_TYPES_LIST, + CALM_NARRATIVE_DOCUMENT_TYPES_LIST, +} from '@finos/calm-models/types'; +import { + classifyWorkspaceDocumentType, + getWorkspaceDocumentLoadPolicy, +} from './workspace-document-kind'; + +describe('workspace document kind', () => { + it.each(CALM_DOCUMENT_TYPES_LIST)('assigns mapping type %s to the JSON loader policy', (type) => { + expect(classifyWorkspaceDocumentType(type)).toEqual({ kind: 'mapping', type }); + expect(getWorkspaceDocumentLoadPolicy(type)).toBe('json'); + }); + + it.each(CALM_NARRATIVE_DOCUMENT_TYPES_LIST)('assigns narrative type %s to the non-JSON loader policy', (type) => { + expect(classifyWorkspaceDocumentType(type)).toEqual({ kind: 'narrative', type }); + expect(getWorkspaceDocumentLoadPolicy(type)).toBe('non-json'); + }); + + it('retains the JSON loader policy for the legacy unknown type', () => { + expect(classifyWorkspaceDocumentType('unknown')).toEqual({ kind: 'mapping', type: 'unknown' }); + expect(getWorkspaceDocumentLoadPolicy('unknown')).toBe('json'); + }); + + it('does not assign a loader policy to an unsupported future type', () => { + expect(classifyWorkspaceDocumentType('future-document-kind')).toBeUndefined(); + expect(getWorkspaceDocumentLoadPolicy('future-document-kind')).toBeUndefined(); + }); +}); diff --git a/shared/src/document-loader/workspace-document-kind.ts b/shared/src/document-loader/workspace-document-kind.ts new file mode 100644 index 000000000..b099f1982 --- /dev/null +++ b/shared/src/document-loader/workspace-document-kind.ts @@ -0,0 +1,37 @@ +import { + isNarrativeDocumentType, + isValidCalmDocumentType, + type CalmDocumentType, + type NarrativeDocumentType, +} from '@finos/calm-models/types'; + +export type WorkspaceDocumentKind = 'mapping' | 'narrative'; + +export type ClassifiedWorkspaceDocumentType = + | { kind: 'mapping'; type: CalmDocumentType | 'unknown' } + | { kind: 'narrative'; type: NarrativeDocumentType }; + +export const WORKSPACE_DOCUMENT_LOAD_POLICIES = { + mapping: 'json', + narrative: 'non-json', +} as const satisfies Record; + +export type WorkspaceDocumentLoadPolicy = + typeof WORKSPACE_DOCUMENT_LOAD_POLICIES[WorkspaceDocumentKind]; + +/** Classify only supported workspace document types and the legacy `unknown` type. */ +export function classifyWorkspaceDocumentType(type: unknown): ClassifiedWorkspaceDocumentType | undefined { + if (typeof type !== 'string') return undefined; + if (isValidCalmDocumentType(type) || type === 'unknown') { + return { kind: 'mapping', type }; + } + if (isNarrativeDocumentType(type)) { + return { kind: 'narrative', type }; + } + return undefined; +} + +export function getWorkspaceDocumentLoadPolicy(type: unknown): WorkspaceDocumentLoadPolicy | undefined { + const document = classifyWorkspaceDocumentType(type); + return document === undefined ? undefined : WORKSPACE_DOCUMENT_LOAD_POLICIES[document.kind]; +} diff --git a/shared/src/document-loader/workspace-document-loader.spec.ts b/shared/src/document-loader/workspace-document-loader.spec.ts index b9bfdb8e2..41bf93543 100644 --- a/shared/src/document-loader/workspace-document-loader.spec.ts +++ b/shared/src/document-loader/workspace-document-loader.spec.ts @@ -25,19 +25,19 @@ const BUNDLE = '/ws'; const PATTERN_ID = 'https://hub.example.com/calm/namespaces/ws/patterns/workshop/versions/1.0.0'; const patternDoc = { '$id': PATTERN_ID, title: 'Workshop Pattern' }; // A document whose $id is a host-less CalmHub path, so a full URL can match it by path. -const STANDARD_ID = '/calm/namespaces/ws/standards/security/versions/1.0.0'; -const standardDoc = { '$id': STANDARD_ID, title: 'Security Standard' }; +const SCHEMA_ID = '/calm/namespaces/ws/schemas/security/versions/1.0.0'; +const schemaDoc = { '$id': SCHEMA_ID, title: 'Security Schema' }; const noIdDoc = { title: 'No Id Document' }; function setupBundle(extra: Record = {}) { vol.fromJSON({ '/ws/workspace-manifest.json': JSON.stringify({ 'workshop-pattern': { path: 'files/workshop-pattern.json', type: 'pattern' }, - 'security-standard': { path: 'files/security-standard.json', type: 'standard' }, + 'security-schema': { path: 'files/security-schema.json', type: 'schema' }, 'no-id-doc': { path: 'files/no-id.json', type: 'architecture' }, }), '/ws/files/workshop-pattern.json': JSON.stringify(patternDoc), - '/ws/files/security-standard.json': JSON.stringify(standardDoc), + '/ws/files/security-schema.json': JSON.stringify(schemaDoc), '/ws/files/no-id.json': JSON.stringify(noIdDoc), ...extra, }); @@ -76,7 +76,7 @@ describe('WorkspaceDocumentLoader', () => { // A host-less $id should not match a ref from an arbitrary host — the ref may // point to a completely different service on the same CalmHub path. const loader = new WorkspaceDocumentLoader(BUNDLE); - const url = 'https://any-host.example.com/calm/namespaces/ws/standards/security/versions/3.0.0'; + const url = 'https://any-host.example.com/calm/namespaces/ws/schemas/security/versions/3.0.0'; expect(loader.resolvePath(url)).toBeUndefined(); }); @@ -96,8 +96,8 @@ describe('WorkspaceDocumentLoader', () => { it('resolves an unversioned path ref that equals the $id base path', () => { // A $ref with no /versions/ segment should still resolve locally. const loader = new WorkspaceDocumentLoader(BUNDLE); - const unversioned = '/calm/namespaces/ws/standards/security'; - expect(loader.resolvePath(unversioned)).toBe('/ws/files/security-standard.json'); + const unversioned = '/calm/namespaces/ws/schemas/security'; + expect(loader.resolvePath(unversioned)).toBe('/ws/files/security-schema.json'); }); it('ignores a #/... fragment when matching', () => { @@ -161,6 +161,35 @@ describe('WorkspaceDocumentLoader', () => { // Document without an $id is stored only by its bare id. expect(mocks.schemaDirectory.storeDocument).toHaveBeenCalledWith('no-id-doc', 'schema', noIdDoc); }); + + it('ignores Markdown narrative documents', async () => { + setupBundle({ + '/ws/workspace-manifest.json': JSON.stringify({ + 'payments-sad': { path: 'files/payments-sad.md', type: 'sad' }, + }), + '/ws/files/payments-sad.md': '---\\ntitle: Payments SAD\\n---\\n# Payments\\n', + }); + const loader = new WorkspaceDocumentLoader(BUNDLE); + + await loader.initialise(mocks.schemaDirectory as unknown as SchemaDirectory); + + expect(mocks.schemaDirectory.storeDocument).not.toHaveBeenCalled(); + }); + + it('ignores unsupported future document types', async () => { + setupBundle({ + '/ws/workspace-manifest.json': JSON.stringify({ + future: { path: 'files/future.json', type: 'future-document-kind' }, + }), + '/ws/files/future.json': JSON.stringify({ '$id': 'future' }), + }); + const loader = new WorkspaceDocumentLoader(BUNDLE); + + await loader.initialise(mocks.schemaDirectory as unknown as SchemaDirectory); + + expect(loader.resolvePath('future')).toBeUndefined(); + expect(mocks.schemaDirectory.storeDocument).not.toHaveBeenCalled(); + }); }); describe('with no usable manifest', () => { diff --git a/shared/src/document-loader/workspace-document-loader.ts b/shared/src/document-loader/workspace-document-loader.ts index 3caa4571e..63acf2ab4 100644 --- a/shared/src/document-loader/workspace-document-loader.ts +++ b/shared/src/document-loader/workspace-document-loader.ts @@ -5,6 +5,11 @@ import { readFile } from 'fs/promises'; import { existsSync, readFileSync } from 'fs'; import { SchemaDirectory } from '../schema-directory'; import path from 'path'; +import { + getWorkspaceDocumentLoadPolicy, + WORKSPACE_DOCUMENT_LOAD_POLICIES, + type WorkspaceDocumentLoadPolicy, +} from './workspace-document-kind'; // Mirrors MANIFEST_FILENAME in the CLI workspace bundle module. const MANIFEST_FILENAME = 'workspace-manifest.json'; @@ -86,6 +91,20 @@ export class WorkspaceDocumentLoader implements DocumentLoader { : undefined); if (!relPath) continue; + const type = value && typeof value === 'object' + ? (value as { type?: unknown }).type + : undefined; + const loadPolicy = typeof value === 'string' + ? WORKSPACE_DOCUMENT_LOAD_POLICIES.mapping + : getWorkspaceDocumentLoadPolicy(type); + if (loadPolicy === undefined) { + this.logger.warn(`Ignoring '${bareId}' with unsupported workspace document type '${String(type)}'.`); + continue; + } + if (!usesJsonLoader(loadPolicy)) { + continue; + } + const localPath = path.isAbsolute(relPath) ? relPath : path.resolve(this.bundlePath, relPath); const rule: WorkspaceRule = { bareId, localPath }; @@ -206,3 +225,18 @@ export class WorkspaceDocumentLoader implements DocumentLoader { return this.resolveRule(reference); } } + +function usesJsonLoader(policy: WorkspaceDocumentLoadPolicy): boolean { + switch (policy) { + case 'json': + return true; + case 'non-json': + return false; + default: + return assertNever(policy); + } +} + +function assertNever(value: never): never { + throw new Error(`Unsupported workspace document load policy '${String(value)}'.`); +} diff --git a/shared/src/hub/calm-hub-client.spec.ts b/shared/src/hub/calm-hub-client.spec.ts index c337d1bdf..f45d54395 100644 --- a/shared/src/hub/calm-hub-client.spec.ts +++ b/shared/src/hub/calm-hub-client.spec.ts @@ -15,6 +15,89 @@ describe('CalmHubClient', () => { client = new CalmHubClient({ calmHubUrl: 'http://localhost:8080' }, ax); }); + describe('narrative documents', () => { + const request = { name: 'Payments SAD', description: 'Decisions', documentMarkdown: '---\ntitle: Payments SAD\n---\n# Payments' }; + + it('creates a narrative document using the first-class endpoint', async () => { + mock.onPost('/api/calm/namespaces/finos/documents/sad').reply(201, null, { + location: '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', + }); + await expect(client.createNarrativeDocument('finos', 'sad', request)).resolves.toContain('/42/versions/1.0.0'); + expect(mock.history.post[0].data).toBe(JSON.stringify(request)); + }); + + it('returns undefined after a confirmed create response without Location', async () => { + mock.onPost('/api/calm/namespaces/finos/documents/sad').reply(201, null, {}); + await expect(client.createNarrativeDocument('finos', 'sad', request)).resolves.toBeUndefined(); + }); + + it('still rejects a genuine narrative create failure', async () => { + const endpoint = '/api/calm/namespaces/finos/documents/sad'; + mock.onPost(endpoint).reply(500, { error: 'unavailable' }); + + await expect(client.createNarrativeDocument('finos', 'sad', request)).rejects.toMatchObject({ + status: 500, + request: `POST ${endpoint}`, + }); + }); + + it('lists narrative document IDs from the type-scoped endpoint', async () => { + const endpoint = '/api/calm/namespaces/finos/documents/sad'; + mock.onGet(endpoint).reply(200, { values: [1, 2, 42] }); + + await expect(client.getNarrativeDocumentIds('finos', 'sad')).resolves.toEqual([1, 2, 42]); + expect(mock.history.get[0].url).toBe(endpoint); + }); + + it.each([ + {}, + { values: '1' }, + { values: [0] }, + { values: [-1] }, + { values: [1.5] }, + { values: [Number.MAX_SAFE_INTEGER + 1] }, + { values: ['1'] }, + ])('rejects a malformed narrative document ID list: %j', async (body) => { + mock.onGet('/api/calm/namespaces/finos/documents/sad').reply(200, body); + + await expect(client.getNarrativeDocumentIds('finos', 'sad')).rejects.toBeInstanceOf(HubClientError); + }); + + it('creates a typed later version at the version endpoint', async () => { + const endpoint = '/api/calm/namespaces/finos/documents/knowledge/42/versions/1.1.0'; + mock.onPost(endpoint).reply(201, null, { location: endpoint }); + + await expect(client.createNarrativeDocumentVersion('finos', 'knowledge', 42, '1.1.0', request)).resolves.toBe(endpoint); + expect(mock.history.post[0].data).toBe(JSON.stringify(request)); + }); + + it('reads typed Markdown and rejects malformed success bodies', async () => { + const endpoint = '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0'; + mock.onGet(endpoint).replyOnce(200, { documentMarkdown: '# Payments' }); + await expect(client.getNarrativeDocumentVersion('finos', 'sad', 42, '1.0.0')).resolves.toEqual({ documentMarkdown: '# Payments' }); + mock.onGet(endpoint).replyOnce(200, {}); + await expect(client.getNarrativeDocumentVersion('finos', 'sad', 42, '1.0.0')).rejects.toBeInstanceOf(HubClientError); + }); + + it('requires a string version array', async () => { + const endpoint = '/api/calm/namespaces/finos/documents/sad/42/versions'; + mock.onGet(endpoint).replyOnce(200, { values: ['1.0.0'] }); + await expect(client.getNarrativeDocumentVersions('finos', 'sad', 42)).resolves.toEqual(['1.0.0']); + mock.onGet(endpoint).replyOnce(200, { values: [42] }); + await expect(client.getNarrativeDocumentVersions('finos', 'sad', 42)).rejects.toBeInstanceOf(HubClientError); + }); + + it.each([404, 500])('wraps a %i response from a narrative endpoint', async (status) => { + const endpoint = '/api/calm/namespaces/finos/documents/sad/42/versions'; + mock.onGet(endpoint).replyOnce(status, { error: 'unavailable' }); + + await expect(client.getNarrativeDocumentVersions('finos', 'sad', 42)).rejects.toMatchObject({ + status, + request: `GET ${endpoint}`, + }); + }); + }); + // ── createNamespace ────────────────────────────────────────────────────── describe('createNamespace', () => { @@ -136,6 +219,28 @@ describe('CalmHubClient', () => { expect(authMock.history.get[0].headers?.Authorization).toBe('Bearer test-token'); }); + it('injects auth headers on narrative document requests', async () => { + authMock.onPost('/api/calm/namespaces/finos/documents/sad').reply(201, null, { + location: '/api/calm/namespaces/finos/documents/sad/42/versions/1.0.0', + }); + + await authClient.createNarrativeDocument('finos', 'sad', { + name: 'Payments SAD', documentMarkdown: '# Payments', + }); + + expect(getAuthHeaders).toHaveBeenCalledOnce(); + expect(authMock.history.post[0].headers?.Authorization).toBe('Bearer test-token'); + }); + + it('injects auth headers when listing narrative document IDs', async () => { + authMock.onGet('/api/calm/namespaces/finos/documents/sad').reply(200, { values: [] }); + + await authClient.getNarrativeDocumentIds('finos', 'sad'); + + expect(getAuthHeaders).toHaveBeenCalledOnce(); + expect(authMock.history.get[0].headers?.Authorization).toBe('Bearer test-token'); + }); + it('does not call getAuthHeaders when no auth plugin is configured', async () => { mock.onGet('/api/calm/namespaces').reply(200, { values: [] }); diff --git a/shared/src/hub/calm-hub-client.ts b/shared/src/hub/calm-hub-client.ts index acb48aa34..c4e6821dc 100644 --- a/shared/src/hub/calm-hub-client.ts +++ b/shared/src/hub/calm-hub-client.ts @@ -1,4 +1,5 @@ import axios, { Axios } from 'axios'; +import type { NarrativeDocumentType } from '@finos/calm-models/types'; import { AuthPlugin } from '../auth/auth-plugin'; import { initLogger, Logger } from '../logger'; import { DocumentMetadata, extractDocumentMetadata, validateDocumentId } from './document-id-utils'; @@ -42,6 +43,16 @@ export interface HubControlSummary { export type ResourceChangeType = 'MAJOR' | 'MINOR' | 'PATCH'; +export interface NarrativeDocumentRequest { + name: string; + description?: string; + documentMarkdown: string; +} + +export interface NarrativeDocumentVersion { + documentMarkdown: string; +} + export class HubClientError extends Error { /** * Creates a normalized Hub client error. @@ -116,6 +127,99 @@ export class CalmHubClient { } } + // ── Narrative documents ───────────────────────────────────────────────── + + async createNarrativeDocument( + namespace: string, + type: NarrativeDocumentType, + request: NarrativeDocumentRequest + ): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}`; + try { + const response = await this.ax.post(endpoint, request); + return response.headers.location as string | undefined; + } catch (err) { + throw this.wrapError(err, `POST ${endpoint}`); + } + } + + async createNarrativeDocumentVersion( + namespace: string, + type: NarrativeDocumentType, + id: number, + version: string, + request: NarrativeDocumentRequest + ): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}/${id}/versions/${version}`; + return this.createNarrativeDocumentAt(endpoint, request, `POST ${endpoint}`); + } + + async getNarrativeDocumentIds(namespace: string, type: NarrativeDocumentType): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}`; + try { + const response = await this.ax.get(endpoint); + if (!response.data || typeof response.data !== 'object' || !Array.isArray(response.data.values) || + !response.data.values.every((value: unknown) => Number.isSafeInteger(value) && (value as number) > 0)) { + throw new HubClientError(0, 'Response does not contain a positive integer values array', `GET ${endpoint}`); + } + return response.data.values; + } catch (err) { + if (err instanceof HubClientError) throw err; + throw this.wrapError(err, `GET ${endpoint}`); + } + } + + async getNarrativeDocumentVersions(namespace: string, type: NarrativeDocumentType, id: number): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}/${id}/versions`; + try { + const response = await this.ax.get(endpoint); + if (!response.data || typeof response.data !== 'object' || !Array.isArray(response.data.values) || + !response.data.values.every((value: unknown) => typeof value === 'string')) { + throw new HubClientError(0, 'Response does not contain a string values array', `GET ${endpoint}`); + } + return response.data.values; + } catch (err) { + throw this.wrapError(err, `GET ${endpoint}`); + } + } + + async getNarrativeDocumentVersion( + namespace: string, + type: NarrativeDocumentType, + id: number, + version: string + ): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}/${id}/versions/${version}`; + try { + const response = await this.ax.get(endpoint); + if (!response.data || typeof response.data !== 'object' || typeof response.data.documentMarkdown !== 'string') { + throw new HubClientError(0, 'Response does not contain documentMarkdown', `GET ${endpoint}`); + } + return { documentMarkdown: response.data.documentMarkdown }; + } catch (err) { + if (err instanceof HubClientError) throw err; + throw this.wrapError(err, `GET ${endpoint}`); + } + } + + private async createNarrativeDocumentAt( + endpoint: string, + request: NarrativeDocumentRequest, + requestLabel: string + ): Promise { + try { + const response = await this.ax.post(endpoint, request); + const location = response.headers.location as string | undefined; + if (!location) { + throw new HubClientError(0, 'Response does not include Location header', requestLabel); + } + return location; + } catch (err) { + if (err instanceof HubClientError) throw err; + throw this.wrapError(err, requestLabel); + } + } + /** * Lists namespaces. * @returns Namespace summaries. diff --git a/shared/src/index.ts b/shared/src/index.ts index ec8f2eaec..1ee2c7c9b 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -53,6 +53,7 @@ export * from './template/types.js'; export { parseFrontMatter, parseFrontMatterFromContent, + parseYamlFrontMatterMapping, hasArchitectureFrontMatter, replaceVariables, injectFrontMatter, @@ -69,6 +70,10 @@ export { DocumentLoader, DocumentLoaderOptions, DocumentLoadError, assertJsonObj export { buildDocumentLoader } from './document-loader/node-document-loader.js'; export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader.js'; export { WorkspaceDocumentLoader } from './document-loader/workspace-document-loader.js'; +export { + classifyWorkspaceDocumentType, + type WorkspaceDocumentKind, +} from './document-loader/workspace-document-kind.js'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension, @@ -86,6 +91,8 @@ export { type HubDomainSummary, type HubControlSummary, type CalmHubOptions, + type NarrativeDocumentRequest, + type NarrativeDocumentVersion, type ResourceType, type ResourceChangeType, isValidResourceType diff --git a/shared/src/template/front-matter.ts b/shared/src/template/front-matter.ts index 495782a4c..933085484 100644 --- a/shared/src/template/front-matter.ts +++ b/shared/src/template/front-matter.ts @@ -12,6 +12,17 @@ export interface ParsedFrontMatter { urlToLocalPathMapping?: Map; } +const YAML_FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/; + +/** Parses only a leading YAML mapping. It does not resolve template-specific paths. */ +export function parseYamlFrontMatterMapping(content: string): Record | null { + const match = YAML_FRONTMATTER_PATTERN.exec(content); + if (!match) return null; + const parsed = yaml.parse(match[1]); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed as Record; +} + const RESERVED_KEYS = new Set([ 'architecture', 'url-to-local-file-mapping'