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 dc68647f3..8cbfcfa86 100644 --- a/cli/README.md +++ b/cli/README.md @@ -803,25 +803,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 @@ -829,6 +835,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]` @@ -863,7 +875,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] @@ -887,6 +901,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..1146ac224 --- /dev/null +++ b/cli/smoke/workspace-documents.smoke.spec.ts @@ -0,0 +1,62 @@ +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'); + }); +}); diff --git a/cli/src/command-helpers/workspace/bump.spec.ts b/cli/src/command-helpers/workspace/bump.spec.ts index c0490e1a1..ce17947e1 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/src/hub/calm-hub-client'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; @@ -16,10 +16,14 @@ 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; describe('bump', () => { @@ -48,6 +52,129 @@ 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('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('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('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' } }); diff --git a/cli/src/command-helpers/workspace/bump.ts b/cli/src/command-helpers/workspace/bump.ts index 473dc5cd5..8fbd07b85 100644 --- a/cli/src/command-helpers/workspace/bump.ts +++ b/cli/src/command-helpers/workspace/bump.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; -import { loadManifest, resolveFilePath } from './bundle'; +import { loadManifest, resolveFilePath, saveManifest } from './bundle'; import { buildRefRulesFromDiskIds, syncReferences, RefUpdateResult } from './ref-rewrite'; import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; import { @@ -11,6 +11,8 @@ import { import { computeSemVerBump, sortSemVer } from '@finos/calm-shared/src/hub/semver'; import { canonicalEqual } from '@finos/calm-shared/src/hub/canonical'; import { initLogger, Logger } from '@finos/calm-shared/src/logger'; +import { isNarrativeDocumentType } from '@finos/calm-models/types'; +import { NarrativeDocumentIdentity, parseNarrativeDocument, validateNarrativeDocumentLocation, validateNarrativeIdentity, validateNarrativeNamespace } from './narrative-document'; // Re-exported for existing consumers (push.ts, tests) that import it from here. export { canonicalEqual }; @@ -37,14 +39,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[]; @@ -90,6 +95,7 @@ export async function detectChangedResources( for (const [id, entry] of Object.entries(manifest)) { const filePath = resolveFilePath(bundlePath, entry.path); if (!existsSync(filePath)) { + if (isNarrativeDocumentType(entry.type)) throw new Error(`Narrative document '${id}' file not found: ${filePath}`); logger.warn(`File not found for id '${id}': ${filePath}`); continue; } @@ -98,10 +104,42 @@ export async function detectChangedResources( try { raw = await readFile(filePath, 'utf8'); } catch (e) { + if (isNarrativeDocumentType(entry.type)) 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; } + if (isNarrativeDocumentType(entry.type)) { + // Bump stops on invalid narrative state because it writes local manifest versions; push can report independent failures together. + const version = entry.version; + if (!version) throw new Error(`Narrative document '${id}' has no manifest version.`); + if ((entry.calmHubId === undefined) !== (entry.calmHubDocumentId === undefined)) { + throw new Error(`Narrative document '${id}' has incomplete Hub identity. Re-add the document to repair it.`); + } + validateNarrativeNamespace(entry.namespace, id); + const identity: NarrativeDocumentIdentity = { + namespace: entry.namespace, type: entry.type, version, calmHubDocumentId: entry.calmHubDocumentId, + }; + parseNarrativeDocument(raw, id); + if (entry.calmHubDocumentId === undefined) { + validateNarrativeIdentity(identity, false, id); + continue; + } + validateNarrativeIdentity(identity, true, id); + validateNarrativeDocumentLocation(entry.calmHubId, identity, false); + const versions = await client.getNarrativeDocumentVersions(identity.namespace, identity.type, identity.calmHubDocumentId!); + if (versions.length === 0 || !versions.includes(version)) continue; + const remote = await client.getNarrativeDocumentVersion( + identity.namespace, identity.type, identity.calmHubDocumentId!, version + ); + if (remote.documentMarkdown === raw) continue; + changed.push({ + id, filePath, currentVersion: version, + latestHubVersion: sortSemVer(versions)[versions.length - 1], kind: 'narrative', + }); + continue; + } + let metadata: DocumentMetadata; try { metadata = extractDocumentMetadata(raw); @@ -141,6 +179,7 @@ export async function detectChangedResources( metadata, currentVersion: metadata.version, latestHubVersion: sortSemVer(versions)[versions.length - 1], + kind: 'mapping', }); } @@ -171,6 +210,18 @@ export async function bumpWorkspace( for (const c of changed) { const docIncrement = options.perDocIncrements?.get(c.id) ?? options.increment; const toVersion = computeSemVerBump(c.latestHubVersion, docIncrement); + if (c.kind === 'narrative') { + const manifest = await loadManifest(bundlePath); + const entry = manifest[c.id]; + if (!entry) throw new Error(`Narrative document '${c.id}' is no longer in the manifest.`); + manifest[c.id] = { ...entry, version: toVersion }; + await saveManifest(bundlePath, manifest); + 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'); @@ -191,8 +242,11 @@ export async function bumpWorkspace( for (let depth = 0; depth < MAX_CASCADE_DEPTH; depth++) { const manifest = await loadManifest(bundlePath); - const rules = await buildRefRulesFromDiskIds(manifest, bundlePath); - const refUpdates = await syncReferences(bundlePath, manifest, rules); + const jsonManifest = Object.fromEntries( + Object.entries(manifest).filter(([, entry]) => !isNarrativeDocumentType(entry.type)) + ); + const rules = await buildRefRulesFromDiskIds(jsonManifest, bundlePath); + const refUpdates = await syncReferences(bundlePath, jsonManifest, rules); allRefUpdates.push(...refUpdates); const cascadeCandidates = refUpdates.filter(r => r.changeCount > 0 && !bumpedIds.has(r.docId)); diff --git a/cli/src/command-helpers/workspace/bundle.spec.ts b/cli/src/command-helpers/workspace/bundle.spec.ts index cdfe8ce3b..b15fe1b34 100644 --- a/cli/src/command-helpers/workspace/bundle.spec.ts +++ b/cli/src/command-helpers/workspace/bundle.spec.ts @@ -203,6 +203,31 @@ 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.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)).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 3cf21c4d1..7be71a5a4 100644 --- a/cli/src/command-helpers/workspace/bundle.ts +++ b/cli/src/command-helpers/workspace/bundle.ts @@ -3,7 +3,7 @@ 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 type { CalmDocumentType, NarrativeDocumentType } from '@finos/calm-models/types'; /** * Property names that can contain document references (URLs or paths) in CALM JSON. @@ -63,13 +63,15 @@ 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 = { path: string; type: WorkspaceDocumentType; namespace?: string; calmHubId?: string; + version?: string; + calmHubDocumentId?: number; }; export type WorkspaceManifest = Record; @@ -167,9 +169,24 @@ 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?: { + id?: string; + destName?: string; + copy?: boolean; + type?: WorkspaceDocumentType; + namespace?: string; + version?: string; + calmHubDocumentId?: number; + calmHubId?: string; + } ): 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); let rel: string; let destPath: string; @@ -190,7 +207,13 @@ export async function addFileToBundle( } const manifest = await loadManifest(bundlePath); - manifest[id] = { path: rel, type: opts?.type ?? 'unknown', ...(opts?.namespace ? { namespace: opts.namespace } : {}) }; + manifest[id] = { + path: rel, + type: opts?.type ?? 'unknown', + ...(opts?.namespace ? { namespace: opts.namespace } : {}), + ...(opts?.version ? { version: opts.version } : {}), + ...(hasDocumentId ? { calmHubDocumentId: opts!.calmHubDocumentId, calmHubId: opts!.calmHubId } : {}), + }; 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 a9d75bf57..000d6e979 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'), @@ -168,6 +174,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']); @@ -223,6 +235,137 @@ 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('recovers a verified narrative document without creating 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', + '--calm-hub-document-id', '42', '--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', 42, '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: 42, + calmHubId: '/api/calm/namespaces/finos/documents/sad/42/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([ + ['--calm-hub-document-id', '0', '--ver', '1.2.0'], + ['--calm-hub-document-id', '42', '--ver', 'invalid'], + ])('rejects invalid recovery identity values', async (idOption, id, versionOption, version) => { + await expect(program.parseAsync([ + 'node', 'test', 'workspace', 'add', 'payments.md', '--type', 'sad', '--namespace', 'finos', + idOption, id, versionOption, version + ])).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 62144d0a4..249ec31b6 100644 --- a/cli/src/command-helpers/workspace/commands.ts +++ b/cli/src/command-helpers/workspace/commands.ts @@ -13,11 +13,12 @@ import { loadWorkspaceConfig } from './config'; import { findWorkspaceManifestPath, findGitRoot } from '../../workspace-resolver'; import { initLogger, Logger } from '@finos/calm-shared/src/logger'; 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, isNarrativeDocumentType, isValidCalmDocumentType } from '@finos/calm-models/types'; import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; import { isConformantDocumentId, namespaceFromDocumentId } from '@finos/calm-shared/src/hub/document-id-utils'; import { loadCliConfig } from '../../cli-config'; import { resolveCalmHubOptions } from '../hub-commands'; +import { constructNarrativeDocumentPath, parseNarrativeDocument, validateNarrativeIdentity } from './narrative-document'; const logger: Logger = initLogger(false, 'workspace'); @@ -54,9 +55,20 @@ 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: { + id?: string; + copy?: boolean; + type?: string; + namespace?: string; + calmHubDocumentId?: string; + ver?: string; + calmHubUrl?: string; + }) => { try { const bundlePath = findWorkspaceManifestPath(process.cwd()); if (!bundlePath) { @@ -66,7 +78,73 @@ export function setupWorkspaceCommands(program: Command) { const srcPath = path.resolve(file); - const type = await enforceOptionPresenceByPrompt(options.type, 'Select a document type:', CALM_DOCUMENT_TYPES_LIST); + 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 || !isNarrativeDocumentType(options.type)) { + throw new Error('Narrative recovery requires a narrative --type.'); + } + if (!options.namespace?.trim()) { + throw new Error(`Narrative document '${file}' recovery requires --namespace.`); + } + + const identity = { + namespace: options.namespace.trim(), + type: options.type, + version: options.ver, + calmHubDocumentId: Number(options.calmHubDocumentId), + }; + validateNarrativeIdentity(identity, true, file); + const raw = await readFile(srcPath, 'utf8'); + const narrative = parseNarrativeDocument(raw, file); + 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}.`); + } + const { id: resolvedId, destPath: finalDestPath } = await addFileToBundle(bundlePath, srcPath, { + id: options.id ?? narrative.request.name, + copy: options.copy, + type: identity.type, + namespace: identity.namespace, + version: identity.version, + calmHubDocumentId: identity.calmHubDocumentId, + calmHubId: constructNarrativeDocumentPath(identity), + }); + logger.info(`${options.copy ? 'Copied' : 'Added reference to'} ${finalDestPath} (id: ${resolvedId})`); + return; + } + + const documentTypes = [...CALM_DOCUMENT_TYPES_LIST, ...CALM_NARRATIVE_DOCUMENT_TYPES_LIST]; + const type = await enforceOptionPresenceByPrompt(options.type, 'Select a document type:', documentTypes); + if (isNarrativeDocumentType(type)) { + if (!options.namespace?.trim()) { + throw new Error(`Narrative document '${file}' requires --namespace.`); + } + const raw = await readFile(srcPath, 'utf8'); + const narrative = parseNarrativeDocument(raw, file); + const { id: resolvedId, destPath: finalDestPath } = await addFileToBundle(bundlePath, srcPath, { + id: options.id ?? narrative.request.name, + copy: options.copy, + type, + namespace: options.namespace.trim(), + version: '1.0.0', + }); + if (options.copy) { + logger.info(`Copied ${srcPath} -> ${finalDestPath} (id: ${resolvedId})`); + } else { + logger.info(`Added reference to ${finalDestPath} (id: ${resolvedId})`); + } + return; + } if (!isValidCalmDocumentType(type)) { logger.error(`Invalid document type '${type}'. Must be one of: ${CALM_DOCUMENT_TYPES_LIST.join(', ')}`); process.exit(1); @@ -549,4 +627,3 @@ async function enforceOptionPresenceByPrompt(cliInput: string | undefined, promp message: prompt }); }; - 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..a1be73d07 --- /dev/null +++ b/cli/src/command-helpers/workspace/narrative-document.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST } from '@finos/calm-models/types'; +import { + constructNarrativeDocumentPath, + parseNarrativeDocument, + parseNarrativeDocumentLocation, + validateNarrativeDocumentLocation, + validateNarrativeIdentity, +} from './narrative-document'; + +describe('narrative document helpers', () => { + const identity = { namespace: 'finos', type: 'sad' as const, version: '1.0.0' }; + + 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..108626a2c --- /dev/null +++ b/cli/src/command-helpers/workspace/narrative-document.ts @@ -0,0 +1,134 @@ +import type { NarrativeDocumentRequest } from '@finos/calm-shared/src/hub/calm-hub-client'; +import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST, isNarrativeDocumentType, type NarrativeDocumentType } from '@finos/calm-models/types'; +import { parseYamlFrontMatterMapping } from '@finos/calm-shared/src/template/front-matter'; + +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 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 b00c6e85b..be44c379c 100644 --- a/cli/src/command-helpers/workspace/push.spec.ts +++ b/cli/src/command-helpers/workspace/push.spec.ts @@ -7,11 +7,17 @@ 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'), + getNarrativeDocumentVersions: vi.fn(async () => []), + getNarrativeDocumentVersion: vi.fn(async () => ({ documentMarkdown: '' })), ...overrides, }) as unknown as CalmHubClient; @@ -82,6 +88,150 @@ 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' }, + }); + }); + + 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(/narrative document/); + }); + + 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('fails narrative publish when Hub returns an unexpected Location', 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' }, + }); + const client = makeClient({ createNarrativeDocument: vi.fn().mockResolvedValue('/unexpected') }); + + await expect(pushWorkspaceToHub(bundlePath, client)).rejects.toThrow(/unexpected format/); + expect((await loadManifest(bundlePath)).payments.calmHubDocumentId).toBeUndefined(); + }); + + 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, { diff --git a/cli/src/command-helpers/workspace/push.ts b/cli/src/command-helpers/workspace/push.ts index 81ee2bf4e..d7dea9b50 100644 --- a/cli/src/command-helpers/workspace/push.ts +++ b/cli/src/command-helpers/workspace/push.ts @@ -5,6 +5,14 @@ import { CalmHubClient } from '@finos/calm-shared/src/hub/calm-hub-client'; import { DocumentMetadata, extractDocumentMetadata } from '@finos/calm-shared/src/hub/document-id-utils'; import { initLogger, Logger } from '@finos/calm-shared/src/logger'; import { canonicalEqual } from './bump'; +import { isNarrativeDocumentType } from '@finos/calm-models/types'; +import { + parseNarrativeDocument, + parseNarrativeDocumentLocation, + validateNarrativeDocumentLocation, + validateNarrativeIdentity, + validateNarrativeNamespace, +} from './narrative-document'; const logger: Logger = initLogger(false, 'workspace'); @@ -34,12 +42,14 @@ export async function pushWorkspaceToHub( } const conflicts: string[] = []; + const narrativeFailures: string[] = []; for (const [id, entry] of entries) { const filePath = resolveFilePath(bundlePath, entry.path); if (!existsSync(filePath)) { logger.warn(`File not found for id '${id}': ${filePath}`); + if (isNarrativeDocumentType(entry.type)) narrativeFailures.push(`${id}: file not found`); continue; } @@ -48,6 +58,73 @@ 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 (isNarrativeDocumentType(entry.type)) narrativeFailures.push(`${id}: file could not be read`); + continue; + } + + if (isNarrativeDocumentType(entry.type)) { + try { + const version = entry.version; + if (!version) throw new Error('Narrative document manifest entry has no version. Re-add the document to repair it.'); + if ((entry.calmHubId === undefined) !== (entry.calmHubDocumentId === undefined)) { + throw new Error('Narrative document Hub identity is incomplete. Re-add the document to repair it.'); + } + validateNarrativeNamespace(entry.namespace, id); + const identity = { + namespace: entry.namespace, + type: entry.type, + version, + calmHubDocumentId: entry.calmHubDocumentId, + }; + const narrative = parseNarrativeDocument(raw, id); + + if (entry.calmHubDocumentId === undefined) { + validateNarrativeIdentity(identity, false, id); + if (version !== '1.0.0') { + throw new Error('A narrative document without calmHubDocumentId must use version 1.0.0.'); + } + const location = await client.createNarrativeDocument(identity.namespace, identity.type, narrative.request); + const documentId = parseNarrativeDocumentLocation(location, identity); + manifest[id] = { ...entry, calmHubDocumentId: documentId, calmHubId: location }; + await saveManifest(bundlePath, manifest); + logger.info(`Pushed '${id}' version ${version} -> ${location}`); + continue; + } + + validateNarrativeIdentity(identity, true, id); + 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] = { ...entry, calmHubId: location }; + await saveManifest(bundlePath, manifest); + logger.info(`Pushed '${id}' version ${version} -> ${location}`); + continue; + } + + if (!failIfModified) { + logger.info(`No changes for '${id}' - version ${version} already exists, skipping`); + continue; + } + 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}`); + } continue; } @@ -115,10 +192,19 @@ export async function pushWorkspaceToHub( } } - if (conflicts.length > 0) { + if (conflicts.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.` + ); + } + if (narrativeFailures.length > 0) { + summaries.push(`${narrativeFailures.length} narrative document(s) failed (${narrativeFailures.join('; ')})`); + } 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.` + `Push failed: ${summaries.join(' ')}` ); } } diff --git a/shared/src/document-loader/workspace-document-loader.spec.ts b/shared/src/document-loader/workspace-document-loader.spec.ts index b9bfdb8e2..ffcbcfcae 100644 --- a/shared/src/document-loader/workspace-document-loader.spec.ts +++ b/shared/src/document-loader/workspace-document-loader.spec.ts @@ -161,6 +161,20 @@ 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(); + }); }); 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..502f6377c 100644 --- a/shared/src/document-loader/workspace-document-loader.ts +++ b/shared/src/document-loader/workspace-document-loader.ts @@ -1,5 +1,5 @@ import { DocumentLoader, DocumentLoadError } from './document-loader'; -import type { CalmDocumentType } from '@finos/calm-models/types'; +import { isNarrativeDocumentType, type CalmDocumentType } from '@finos/calm-models/types'; import { initLogger, Logger } from '../logger'; import { readFile } from 'fs/promises'; import { existsSync, readFileSync } from 'fs'; @@ -79,6 +79,12 @@ export class WorkspaceDocumentLoader implements DocumentLoader { const rules: WorkspaceRule[] = []; for (const [bareId, value] of Object.entries(manifest)) { // Manifest entries are `{ path, type, ... }`; tolerate the legacy plain-string form too. + const type = value && typeof value === 'object' + ? (value as { type?: unknown }).type + : undefined; + // Narrative documents are Markdown rather than CALM JSON documents. They cannot be + // schema-preloaded or resolve a CALM `$ref`, so keep them out of this JSON-only loader. + if (isNarrativeDocumentType(type)) continue; const relPath = typeof value === 'string' ? value : (value && typeof value === 'object' && typeof (value as { path?: unknown }).path === 'string' diff --git a/shared/src/hub/calm-hub-client.spec.ts b/shared/src/hub/calm-hub-client.spec.ts index c337d1bdf..206d7311c 100644 --- a/shared/src/hub/calm-hub-client.spec.ts +++ b/shared/src/hub/calm-hub-client.spec.ts @@ -15,6 +15,59 @@ 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('rejects a create response without Location', async () => { + mock.onPost('/api/calm/namespaces/finos/documents/sad').reply(201, null, {}); + await expect(client.createNarrativeDocument('finos', 'sad', request)).rejects.toMatchObject({ + request: 'POST /api/calm/namespaces/finos/documents/sad', + }); + }); + + 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 +189,19 @@ 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('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 56490eb3a..36b6d06ba 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'; @@ -39,6 +40,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 type ResourceType = 'patterns' | 'architectures' | 'standards' | 'interfaces'; export const RESOURCE_TYPES = ['patterns', 'architectures', 'standards', 'interfaces']; @@ -120,6 +131,79 @@ export class CalmHubClient { } } + // ── Narrative documents ───────────────────────────────────────────────── + + async createNarrativeDocument( + namespace: string, + type: NarrativeDocumentType, + request: NarrativeDocumentRequest + ): Promise { + const endpoint = `/api/calm/namespaces/${namespace}/documents/${type}`; + return this.createNarrativeDocumentAt(endpoint, request, `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 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/template/front-matter.ts b/shared/src/template/front-matter.ts index 918dde58c..b0db065af 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'