From 5adfb71208ff76e6843b0e2f4cad7e34af02332e Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:02:59 +0100 Subject: [PATCH 01/25] refactor(cli): import calm-shared through its root barrel instead of src deep paths --- cli/src/cli.e2e.spec.ts | 2 +- cli/src/cli.spec.ts | 2 -- cli/src/command-helpers/ai-tools.ts | 2 +- cli/src/command-helpers/hub-commands.spec.ts | 11 +++++------ cli/src/command-helpers/workspace/bump.spec.ts | 5 +++-- cli/src/command-helpers/workspace/bump.ts | 13 ++++++++----- .../command-helpers/workspace/commands.spec.ts | 15 +++++---------- cli/src/command-helpers/workspace/commands.ts | 4 +--- cli/src/command-helpers/workspace/config.ts | 2 +- .../workspace/document-id-prompt.ts | 5 +++-- cli/src/command-helpers/workspace/push.spec.ts | 5 +++-- cli/src/command-helpers/workspace/push.ts | 4 +--- .../command-helpers/workspace/ref-rewrite.spec.ts | 3 ++- cli/src/command-helpers/workspace/ref-rewrite.ts | 2 +- cli/src/command-helpers/workspace/rm.ts | 2 +- shared/src/index.ts | 10 +++++----- 16 files changed, 41 insertions(+), 46 deletions(-) diff --git a/cli/src/cli.e2e.spec.ts b/cli/src/cli.e2e.spec.ts index 8f064779b..1637ec483 100644 --- a/cli/src/cli.e2e.spec.ts +++ b/cli/src/cli.e2e.spec.ts @@ -2,7 +2,7 @@ import { execSync } from 'child_process'; import path from 'path'; import * as fs from 'fs'; import { parseStringPromise } from 'xml2js'; -import { expectDirectoryMatch, expectFilesMatch } from '@finos/calm-shared'; +import { expectDirectoryMatch, expectFilesMatch } from '../../shared/src/test/file-comparison'; import { installPackedCli, type CliInstall } from './test_helpers/cli-runner'; import { patchJson } from './test_helpers/json-file'; import { STATIC_GETTING_STARTED_MAPPING_PATH } from './test_helpers/getting-started-url-mapping'; diff --git a/cli/src/cli.spec.ts b/cli/src/cli.spec.ts index 012e024c4..38f948177 100644 --- a/cli/src/cli.spec.ts +++ b/cli/src/cli.spec.ts @@ -14,7 +14,6 @@ let templateModule: typeof import('./command-helpers/template'); let optionsModule: typeof import('./command-helpers/generate-options'); let diffModule: typeof import('./command-helpers/diff'); let hubCommandsModule: typeof import('./command-helpers/hub-commands'); -let _fileSystemDocLoaderModule: typeof import('@finos/calm-shared/dist/document-loader/file-system-document-loader'); let documentLoaderModule: typeof import('../../shared/src/document-loader/document-loader'); let setupCLI: typeof import('./cli').setupCLI; let cliConfigModule: typeof import('./cli-config'); @@ -32,7 +31,6 @@ describe('CLI Commands', () => { templateModule = await import('./command-helpers/template'); optionsModule = await import('./command-helpers/generate-options'); diffModule = await import('./command-helpers/diff'); - _fileSystemDocLoaderModule = await import('@finos/calm-shared/dist/document-loader/file-system-document-loader'); documentLoaderModule = await import('../../shared/src/document-loader/document-loader'); vi.spyOn(calmShared, 'runGenerate').mockResolvedValue(undefined); diff --git a/cli/src/command-helpers/ai-tools.ts b/cli/src/command-helpers/ai-tools.ts index acb5ac5dc..f4f3b1b3b 100644 --- a/cli/src/command-helpers/ai-tools.ts +++ b/cli/src/command-helpers/ai-tools.ts @@ -1,5 +1,5 @@ import { initLogger } from '@finos/calm-shared'; -import { Logger } from '@finos/calm-shared/src/logger.js'; +import { Logger } from '@finos/calm-shared'; import { mkdir, writeFile, readFile, stat } from 'fs/promises'; import { dirname, join, resolve } from 'path'; diff --git a/cli/src/command-helpers/hub-commands.spec.ts b/cli/src/command-helpers/hub-commands.spec.ts index fe7fc3f6e..a099dc853 100644 --- a/cli/src/command-helpers/hub-commands.spec.ts +++ b/cli/src/command-helpers/hub-commands.spec.ts @@ -14,11 +14,10 @@ import { runCreateNamespace, runListArchitectures, runListNamespaces, // We stub the @finos/calm-shared HTTP client so no real HTTP is made, but keep the // real (pure) document-id-utils helpers that orchestratePush relies on. vi.mock('@finos/calm-shared', async () => { - const documentIdUtils = await vi.importActual>('@finos/calm-shared/dist/hub/document-id-utils'); - // Real (pure) semver helpers used by pushDocument's version-bump path. - const semver = await vi.importActual('@finos/calm-shared/dist/hub/semver'); - // Real (pure) canonical-equality helper used by pushDocument's fail-if-modified path. - const canonical = await vi.importActual('@finos/calm-shared/dist/hub/canonical'); + const actual = await vi.importActual('@finos/calm-shared'); + const documentIdUtils = actual as unknown as Record; + const semver = actual; + const canonical = actual; const mockClient = { createNamespace: vi.fn(), listNamespaces: vi.fn(), @@ -42,9 +41,9 @@ vi.mock('@finos/calm-shared', async () => { }; return { ...documentIdUtils, - extractDocumentMetadata: vi.fn(documentIdUtils['extractDocumentMetadata'] as (...args: unknown[]) => unknown), ...semver, ...canonical, + extractDocumentMetadata: vi.fn(documentIdUtils['extractDocumentMetadata'] as (...args: unknown[]) => unknown), CalmHubClient: vi.fn(function () { return mockClient; }), HubClientError: class HubClientError extends Error { constructor(public status: number, public error: string, public request: string) { diff --git a/cli/src/command-helpers/workspace/bump.spec.ts b/cli/src/command-helpers/workspace/bump.spec.ts index c0490e1a1..7f69aa571 100644 --- a/cli/src/command-helpers/workspace/bump.spec.ts +++ b/cli/src/command-helpers/workspace/bump.spec.ts @@ -1,11 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { detectChangedResources, bumpWorkspace, canonicalEqual, maxIncrement } from './bump'; import { saveManifest } from './bundle'; -import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; +import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); diff --git a/cli/src/command-helpers/workspace/bump.ts b/cli/src/command-helpers/workspace/bump.ts index 473dc5cd5..67bf71dbd 100644 --- a/cli/src/command-helpers/workspace/bump.ts +++ b/cli/src/command-helpers/workspace/bump.ts @@ -2,15 +2,18 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { loadManifest, resolveFilePath } from './bundle'; import { buildRefRulesFromDiskIds, syncReferences, RefUpdateResult } from './ref-rewrite'; -import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; import { + CalmHubClient, + ResourceChangeType, DocumentMetadata, extractDocumentMetadata, constructDocumentId, -} from '@finos/calm-shared/src/hub/document-id-utils'; -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'; + computeSemVerBump, + sortSemVer, + canonicalEqual, + initLogger, + Logger, +} from '@finos/calm-shared'; // Re-exported for existing consumers (push.ts, tests) that import it from here. export { canonicalEqual }; diff --git a/cli/src/command-helpers/workspace/commands.spec.ts b/cli/src/command-helpers/workspace/commands.spec.ts index a9d75bf57..eb8962769 100644 --- a/cli/src/command-helpers/workspace/commands.spec.ts +++ b/cli/src/command-helpers/workspace/commands.spec.ts @@ -93,19 +93,10 @@ vi.mock('../../cli-config', () => ({ loadAuthPlugin: mocks.loadAuthPlugin, })); -vi.mock('@finos/calm-shared/src/hub/calm-hub-client', () => ({ - CalmHubClient: mocks.CalmHubClient, -})); - vi.mock('./document-id-prompt', () => ({ promptForDocumentId: mocks.promptForDocumentId, })); -vi.mock('@finos/calm-shared/src/hub/document-id-utils', () => ({ - isConformantDocumentId: mocks.isConformantDocumentId, - namespaceFromDocumentId: mocks.namespaceFromDocumentId, -})); - vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readFile: mocks.readFile, writeFile: mocks.writeFile }; @@ -116,7 +107,11 @@ vi.mock('@inquirer/prompts', () => ({ input: mocks.input, })); -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), + CalmHubClient: mocks.CalmHubClient, + isConformantDocumentId: mocks.isConformantDocumentId, + namespaceFromDocumentId: mocks.namespaceFromDocumentId, initLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/cli/src/command-helpers/workspace/commands.ts b/cli/src/command-helpers/workspace/commands.ts index 62144d0a4..9dbc36dca 100644 --- a/cli/src/command-helpers/workspace/commands.ts +++ b/cli/src/command-helpers/workspace/commands.ts @@ -11,11 +11,9 @@ import { detectChangedResources, bumpWorkspace } from './bump'; import { runPostBumpValidation } from './post-bump-validate'; import { loadWorkspaceConfig } from './config'; import { findWorkspaceManifestPath, findGitRoot } from '../../workspace-resolver'; -import { initLogger, Logger } from '@finos/calm-shared/src/logger'; +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 { 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'; diff --git a/cli/src/command-helpers/workspace/config.ts b/cli/src/command-helpers/workspace/config.ts index e39113257..57bb95c43 100644 --- a/cli/src/command-helpers/workspace/config.ts +++ b/cli/src/command-helpers/workspace/config.ts @@ -1,7 +1,7 @@ import path from 'path'; import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; -import type { ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; +import type { ResourceChangeType } from '@finos/calm-shared'; /** * Central, repo-level workspace configuration. Committed at diff --git a/cli/src/command-helpers/workspace/document-id-prompt.ts b/cli/src/command-helpers/workspace/document-id-prompt.ts index 304c40d37..31d5943d6 100644 --- a/cli/src/command-helpers/workspace/document-id-prompt.ts +++ b/cli/src/command-helpers/workspace/document-id-prompt.ts @@ -5,8 +5,9 @@ import { isConformantDocumentId, DocumentMetadata, ControlDocumentMetadata, -} from '@finos/calm-shared/src/hub/document-id-utils'; -import { RESOURCE_TYPES, ResourceType } from '@finos/calm-shared/src/hub/calm-hub-client'; + RESOURCE_TYPES, + ResourceType, +} from '@finos/calm-shared'; const DEFAULT_VERSION = '1.0.0'; diff --git a/cli/src/command-helpers/workspace/push.spec.ts b/cli/src/command-helpers/workspace/push.spec.ts index b00c6e85b..6b22e0f0c 100644 --- a/cli/src/command-helpers/workspace/push.spec.ts +++ b/cli/src/command-helpers/workspace/push.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { pushWorkspaceToHub } from './push'; import { loadManifest, saveManifest } from './bundle'; -import { CalmHubClient, HubClientError } from '@finos/calm-shared/src/hub/calm-hub-client'; +import { CalmHubClient, HubClientError } from '@finos/calm-shared'; import { mkdir, writeFile, rm } from 'fs/promises'; import path from 'path'; import { existsSync } from 'fs'; @@ -15,7 +15,8 @@ const makeClient = ( ...overrides, }) as unknown as CalmHubClient; -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/cli/src/command-helpers/workspace/push.ts b/cli/src/command-helpers/workspace/push.ts index 81ee2bf4e..a18a045b8 100644 --- a/cli/src/command-helpers/workspace/push.ts +++ b/cli/src/command-helpers/workspace/push.ts @@ -1,9 +1,7 @@ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { loadManifest, saveManifest, resolveFilePath } from './bundle'; -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 { CalmHubClient, DocumentMetadata, extractDocumentMetadata, initLogger, Logger } from '@finos/calm-shared'; import { canonicalEqual } from './bump'; const logger: Logger = initLogger(false, 'workspace'); diff --git a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts index ac63d5975..da1b6b4ce 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts @@ -12,7 +12,8 @@ import path from 'path'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const loadJson = async (p: string): Promise => JSON.parse(await readFile(p, 'utf8')); -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); diff --git a/cli/src/command-helpers/workspace/ref-rewrite.ts b/cli/src/command-helpers/workspace/ref-rewrite.ts index ed062db69..eef4122b3 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.ts @@ -1,7 +1,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/src/logger'; +import { initLogger, Logger } from '@finos/calm-shared'; const logger: Logger = initLogger(false, 'workspace'); diff --git a/cli/src/command-helpers/workspace/rm.ts b/cli/src/command-helpers/workspace/rm.ts index 4167f49a8..b75be4361 100644 --- a/cli/src/command-helpers/workspace/rm.ts +++ b/cli/src/command-helpers/workspace/rm.ts @@ -1,4 +1,4 @@ -import { initLogger } from '@finos/calm-shared/src/logger'; +import { initLogger } from '@finos/calm-shared'; import { loadManifest, saveManifest } from './bundle'; const logger = initLogger(false, 'workspace-rm'); diff --git a/shared/src/index.ts b/shared/src/index.ts index 8cb74ca1c..4cb9521a0 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -50,12 +50,11 @@ export { export { Docifier, DocifyMode, DiagramExportFormat } from './docify/docifier.js'; export { C4Model } from './docify/graphing/c4.js'; export { CalmRelationshipGraph } from './docify/graphing/relationship-graph.js'; -export { ValidationOutcome } from './commands/validate/validation.output'; -export * from './test/file-comparison.js'; +export { ValidationOutcome } from './commands/validate/validation.output.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; -export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader'; -export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader'; -export { WorkspaceDocumentLoader } from './document-loader/workspace-document-loader'; +export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader.js'; +export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader.js'; +export { WorkspaceDocumentLoader } from './document-loader/workspace-document-loader.js'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension, @@ -65,6 +64,7 @@ export { export { CalmHubClient, HubClientError, + RESOURCE_TYPES, type HubNamespaceSummary, type HubCreateResult, type HubNamespaceCreateResult, From 043fc6559b47bf22046e8a7653f4206a1ed861e0 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:13:27 +0100 Subject: [PATCH 02/25] refactor(shared): move winston behind a node logger factory so the browser graph never imports it --- shared/src/index.ts | 10 +++- shared/src/logger.node.ts | 35 ++++++++++++++ shared/src/logger.spec.ts | 54 ++++++++++++++++++---- shared/src/logger.ts | 72 ++++++++--------------------- shared/src/schema-directory.spec.ts | 3 +- 5 files changed, 107 insertions(+), 67 deletions(-) create mode 100644 shared/src/logger.node.ts diff --git a/shared/src/index.ts b/shared/src/index.ts index 4cb9521a0..01cb84a2b 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,8 @@ +import { registerNodeLoggerFactory } from './logger.js'; +import { createWinstonLogger } from './logger.node.js'; + +registerNodeLoggerFactory(createWinstonLogger); + export { validate, formatOutput as getFormattedOutput, @@ -31,8 +36,9 @@ export { export { ValidationOutput } from './commands/validate/validation.output.js'; export { CALM_META_SCHEMA_DIRECTORY } from './consts.js'; export { SchemaDirectory } from './schema-directory.js'; -export { initLogger } from './logger.js'; -export type { Logger } from './logger.js'; +export { initLogger, registerNodeLoggerFactory } from './logger.js'; +export type { Logger, LogLevel, NodeLoggerFactory } from './logger.js'; +export { createWinstonLogger } from './logger.node.js'; export { AuthPlugin } from './auth/auth-plugin.js'; export { NoAuthPlugin } from './auth/no-auth-plugin.js'; export { TemplateProcessor, TemplateProcessingMode } from './template/template-processor.js'; diff --git a/shared/src/logger.node.ts b/shared/src/logger.node.ts new file mode 100644 index 000000000..43468e3c9 --- /dev/null +++ b/shared/src/logger.node.ts @@ -0,0 +1,35 @@ +import winston from 'winston'; +import { Logger } from './logger.js'; + +/** + * Winston-backed logger for Node.js. Lives in its own module so the browser entry point never + * imports winston (and its fs/os/tty transport chain). The root entry registers this factory + * with {@link registerNodeLoggerFactory} at module load. + */ +export function createWinstonLogger(debug: boolean, label?: string): Logger { + const level = debug ? 'debug' : 'info'; + const winstonLogger = winston.createLogger({ + level, + transports: [ + new winston.transports.Console({ stderrLevels: ['error', 'warn', 'info'] }), + ], + format: winston.format.combine( + winston.format.label({ label }), + winston.format.cli(), + winston.format.errors({ stack: true }), + winston.format.printf(({ level, message, stack, label }) => + stack + ? `${level} [${label}]: ${message} - ${stack}` + : `${level} [${label}]: ${message}` + ) + ), + }); + + return { + log: (lvl, msg) => winstonLogger.log({ level: lvl, message: msg }), + debug: (msg) => winstonLogger.debug(msg), + info: (msg) => winstonLogger.info(msg), + warn: (msg) => winstonLogger.warn(msg), + error: (msg) => winstonLogger.error(msg), + }; +} diff --git a/shared/src/logger.spec.ts b/shared/src/logger.spec.ts index f1aece575..8c6ef17f8 100644 --- a/shared/src/logger.spec.ts +++ b/shared/src/logger.spec.ts @@ -21,17 +21,40 @@ describe('initLogger', () => { delete (globalThis as { window?: typeof globalThis.window }).window; }); - it('returns a logger that exposes debug/info/warn/error', async () => { + it('falls back to loglevel when no node logger factory is registered', async () => { + vi.resetModules(); + const log = (await import('loglevel')).default; + vi.spyOn(log, 'setLevel').mockImplementation(() => {}); + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); const { initLogger } = await import('./logger'); - const logger = initLogger(false); - expect(typeof logger.debug).toBe('function'); - expect(typeof logger.info).toBe('function'); - expect(typeof logger.warn).toBe('function'); - expect(typeof logger.error).toBe('function'); - expect(typeof logger.log).toBe('function'); + initLogger(false).info('hello'); + expect(infoSpy).toHaveBeenCalledWith('hello'); + }); + + it('uses the registered node logger factory', async () => { + vi.resetModules(); + const { initLogger, registerNodeLoggerFactory } = await import('./logger'); + const fake = { log: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const factory = vi.fn(() => fake); + registerNodeLoggerFactory(factory); + const logger = initLogger(true, 'my-label'); + logger.info('b'); + expect(factory).toHaveBeenCalledWith(true, 'my-label'); + expect(fake.info).toHaveBeenCalledWith('b'); }); - it('forwards each level method to winston with the message intact', async () => { + it('ignores the registered factory when quiet=true', async () => { + vi.resetModules(); + const { initLogger, registerNodeLoggerFactory } = await import('./logger'); + const factory = vi.fn(); + registerNodeLoggerFactory(factory); + const logger = initLogger(true, 'x', true); + logger.info('silent'); + expect(factory).not.toHaveBeenCalled(); + }); + + it('createWinstonLogger forwards each level method to winston with the message intact', async () => { + vi.resetModules(); const winston = (await import('winston')).default; const winstonSpy = { log: vi.fn(), @@ -44,8 +67,8 @@ describe('initLogger', () => { winstonSpy as unknown as ReturnType ); - const { initLogger } = await import('./logger'); - const logger = initLogger(true, 'my-label'); + const { createWinstonLogger } = await import('./logger.node'); + const logger = createWinstonLogger(true, 'my-label'); logger.debug('a'); logger.info('b'); @@ -59,6 +82,15 @@ describe('initLogger', () => { expect(winstonSpy.error).toHaveBeenCalledWith('d'); expect(winstonSpy.log).toHaveBeenCalledWith({ level: 'warn', message: 'e' }); }); + + it('the root barrel registers winston as the node logger', async () => { + vi.resetModules(); + const winston = (await import('winston')).default; + const createLogger = vi.spyOn(winston, 'createLogger'); + const { initLogger } = await import('./index'); + initLogger(false, 'via-barrel'); + expect(createLogger).toHaveBeenCalled(); + }); }); describe('browser environment', () => { @@ -67,6 +99,7 @@ describe('initLogger', () => { }); it('returns a browser logger that delegates to loglevel', async () => { + vi.resetModules(); const log = (await import('loglevel')).default; const setLevelSpy = vi.spyOn(log, 'setLevel').mockImplementation(() => {}); const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); @@ -94,6 +127,7 @@ describe('initLogger', () => { }); it('sets debug log level when debug=true', async () => { + vi.resetModules(); const log = (await import('loglevel')).default; const setLevelSpy = vi.spyOn(log, 'setLevel').mockImplementation(() => {}); diff --git a/shared/src/logger.ts b/shared/src/logger.ts index 340312d9e..8cc67e406 100644 --- a/shared/src/logger.ts +++ b/shared/src/logger.ts @@ -1,4 +1,3 @@ -import winston from 'winston'; import log from 'loglevel'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -11,6 +10,20 @@ export interface Logger { error(message: string): void; } +export type NodeLoggerFactory = (debug: boolean, label?: string) => Logger; + +let nodeLoggerFactory: NodeLoggerFactory | undefined; + +/** + * Registers the logger used in Node.js environments. The root entry point registers the + * winston implementation (see `logger.node.ts`); the browser entry registers nothing and + * therefore always uses loglevel. Calling this in a browser has no effect on behaviour because + * `initLogger` only consults the factory when `window` is undefined. + */ +export function registerNodeLoggerFactory(factory: NodeLoggerFactory): void { + nodeLoggerFactory = factory; +} + /** * Initializes a logger that works in both Node.js and browser environments. * @param debug - Enables debug logging if true. @@ -22,66 +35,17 @@ export function initLogger(debug: boolean, label?: string, quiet: boolean = fals if (quiet) { return createQuietLogger(); } - if (typeof window === 'undefined') { - return initNodeLogger(debug, label); - } else { - return initBrowserLogger(debug); + if (typeof window === 'undefined' && nodeLoggerFactory) { + return nodeLoggerFactory(debug, label); } + return initBrowserLogger(debug); } -/** - * Creates a no-op logger that suppresses all output. - */ function createQuietLogger(): Logger { const noop = () => { }; - return { - log: noop, - debug: noop, - info: noop, - warn: noop, - error: noop, - }; + return { log: noop, debug: noop, info: noop, warn: noop, error: noop }; } -/** - * Initializes a logger for Node.js environment using winston. - * @param debug - Whether to enable debug logging. - * @param label - Optional label to prefix Node.js logs. - * @returns Logger instance for Node.js. - */ -function initNodeLogger(debug: boolean, label?: string): Logger { - const level = debug ? 'debug' : 'info'; - const winstonLogger = winston.createLogger({ - level, - transports: [ - new winston.transports.Console({ stderrLevels: ['error', 'warn', 'info'] }), - ], - format: winston.format.combine( - winston.format.label({ label }), - winston.format.cli(), - winston.format.errors({ stack: true }), - winston.format.printf(({ level, message, stack, label }) => - stack - ? `${level} [${label}]: ${message} - ${stack}` - : `${level} [${label}]: ${message}` - ) - ), - }); - - return { - log: (lvl, msg) => winstonLogger.log({ level: lvl, message: msg }), - debug: (msg) => winstonLogger.debug(msg), - info: (msg) => winstonLogger.info(msg), - warn: (msg) => winstonLogger.warn(msg), - error: (msg) => winstonLogger.error(msg), - }; -} - -/** - * Initializes a logger for the browser environment using loglevel. - * @param debug - Whether to enable debug logging. - * @returns Logger instance for browser. - */ function initBrowserLogger(debug: boolean): Logger { const level = debug ? 'debug' : 'info'; log.setLevel(level); diff --git a/shared/src/schema-directory.spec.ts b/shared/src/schema-directory.spec.ts index 8418b7288..64f04a7cd 100644 --- a/shared/src/schema-directory.spec.ts +++ b/shared/src/schema-directory.spec.ts @@ -12,7 +12,8 @@ vi.mock('./logger', () => { warn: () => { }, error: () => { } }; - } + }, + registerNodeLoggerFactory: () => { } }; }); From a4e2b4c461c0dcdfcf3ab4a6dd05507c99d5f782 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:24:54 +0100 Subject: [PATCH 03/25] refactor(shared): move the fs-backed document loader factory out of the loader interface module --- cli/src/cli.spec.ts | 4 +- .../document-loader/document-loader.spec.ts | 201 +----------------- shared/src/document-loader/document-loader.ts | 52 +---- .../node-document-loader.spec.ts | 201 ++++++++++++++++++ .../document-loader/node-document-loader.ts | 49 +++++ shared/src/index.ts | 3 +- .../src/resolver/caching-tracking-resolver.ts | 2 +- .../schema-directory-reference-resolver.ts | 2 +- 8 files changed, 259 insertions(+), 255 deletions(-) create mode 100644 shared/src/document-loader/node-document-loader.spec.ts create mode 100644 shared/src/document-loader/node-document-loader.ts diff --git a/cli/src/cli.spec.ts b/cli/src/cli.spec.ts index 38f948177..f2ab385c6 100644 --- a/cli/src/cli.spec.ts +++ b/cli/src/cli.spec.ts @@ -14,7 +14,7 @@ let templateModule: typeof import('./command-helpers/template'); let optionsModule: typeof import('./command-helpers/generate-options'); let diffModule: typeof import('./command-helpers/diff'); let hubCommandsModule: typeof import('./command-helpers/hub-commands'); -let documentLoaderModule: typeof import('../../shared/src/document-loader/document-loader'); +let documentLoaderModule: typeof import('../../shared/src/document-loader/node-document-loader'); let setupCLI: typeof import('./cli').setupCLI; let cliConfigModule: typeof import('./cli-config'); @@ -31,7 +31,7 @@ describe('CLI Commands', () => { templateModule = await import('./command-helpers/template'); optionsModule = await import('./command-helpers/generate-options'); diffModule = await import('./command-helpers/diff'); - documentLoaderModule = await import('../../shared/src/document-loader/document-loader'); + documentLoaderModule = await import('../../shared/src/document-loader/node-document-loader'); vi.spyOn(calmShared, 'runGenerate').mockResolvedValue(undefined); vi.spyOn(calmShared.TemplateProcessor.prototype, 'processTemplate').mockResolvedValue(undefined); diff --git a/shared/src/document-loader/document-loader.spec.ts b/shared/src/document-loader/document-loader.spec.ts index 7da574805..49962b8f7 100644 --- a/shared/src/document-loader/document-loader.spec.ts +++ b/shared/src/document-loader/document-loader.spec.ts @@ -1,190 +1,4 @@ -import { AuthPlugin } from '../auth/auth-plugin'; -import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; -import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; -import { assertJsonObject, buildDocumentLoader, DocumentLoaderOptions, DocumentLoadError } from './document-loader'; - -const mocks = vi.hoisted(() => { - return { - fsDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - calmHubDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - mappedDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - directDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - workspaceDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }) - }; -}); - - -vi.mock('./file-system-document-loader', () => { - return { - FileSystemDocumentLoader: mocks.fsDocLoader - }; -}); - -vi.mock('./calmhub-document-loader', () => { - return { - CalmHubDocumentLoader: mocks.calmHubDocLoader - }; -}); - -vi.mock('./mapped-document-loader', () => { - return { - MappedDocumentLoader: mocks.mappedDocLoader - }; -}); - -vi.mock('./direct-url-document-loader', () => { - return { - DirectUrlDocumentLoader: mocks.directDocLoader - }; -}); - -vi.mock('./workspace-document-loader', () => { - return { - WorkspaceDocumentLoader: mocks.workspaceDocLoader - }; -}); - -describe('DocumentLoader', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should create a FileSystemDocumentLoader', () => { - - const docLoaderOpts: DocumentLoaderOptions = { - schemaDirectoryPath: 'schemas' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.fsDocLoader).toHaveBeenCalledWith([CALM_META_SCHEMA_DIRECTORY, 'schemas'], false, process.cwd()); - }); - - it('should not create a WorkspaceDocumentLoader when workspaceBundlePath is absent', () => { - buildDocumentLoader({ schemaDirectoryPath: 'schemas' }); - expect(mocks.workspaceDocLoader).not.toHaveBeenCalled(); - }); - - it('should create a WorkspaceDocumentLoader when workspaceBundlePath is provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - workspaceBundlePath: '/repo/.calm-workspace/bundles/default' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.workspaceDocLoader).toHaveBeenCalledWith('/repo/.calm-workspace/bundles/default', false); - }); - - it('should create a CalmHubDocumentLoader when calmHubUrl is defined in loader options', () => { - - const docLoaderOpts: DocumentLoaderOptions = { - calmHubUrl: 'https://example.com' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, undefined); - }); - - it('should pass authplugin to CalmHubDocumentLoader', () => { - - const mockAuthPlugin: AuthPlugin = { - getAuthHeaders: vi.fn() - }; - - const docLoaderOpts: DocumentLoaderOptions = { - calmHubUrl: 'https://example.com', - authPlugin: mockAuthPlugin - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, mockAuthPlugin); - }); - - it('should pass allowedRemoteHosts to DirectUrlDocumentLoader when provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - allowedRemoteHosts: ['schemas.example.com'] - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.directDocLoader).toHaveBeenCalledWith(false, undefined, ['schemas.example.com']); - }); - - it('should create a MappedDocumentLoader when urlToLocalMap is provided', () => { - const urlMap = new Map([ - ['https://example.com/schema.json', 'local/schema.json'] - ]); - - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: urlMap - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, process.cwd(), false); - }); - - it('should create a MappedDocumentLoader when basePath is provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - basePath: '/project/patterns' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(new Map(), '/project/patterns', false); - }); - - it('should create a MappedDocumentLoader with both urlToLocalMap and basePath', () => { - const urlMap = new Map([ - ['https://example.com/schema.json', 'local/schema.json'] - ]); - - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: urlMap, - basePath: '/custom/base' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, '/custom/base', false); - }); - - it('should not create a MappedDocumentLoader when neither urlToLocalMap nor basePath provided', () => { - const docLoaderOpts: DocumentLoaderOptions = {}; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); - }); - - it('should not create a MappedDocumentLoader when urlToLocalMap is empty and no basePath', () => { - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: new Map() - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); - }); -}); +import { assertJsonObject, DocumentLoadError } from './document-loader'; describe('DocumentLoadError', () => { it('defaults to recoverable so unmarked errors fall through to the next loader', () => { @@ -220,16 +34,3 @@ describe('assertJsonObject', () => { expect((thrown as DocumentLoadError).message).toBe(`Expected a JSON object from calm:/foo but received: ${kind}`); }); }); - -describe('isValidCalmDocumentType', () => { - it.each(CALM_DOCUMENT_TYPES_LIST)('returns true for the valid document type %s', (type) => { - expect(isValidCalmDocumentType(type)).toBe(true); - }); - - it.each(['unknown', 'architectures', 'Pattern', '', 'foo'])( - 'returns false for the invalid document type %s', - (type) => { - expect(isValidCalmDocumentType(type)).toBe(false); - } - ); -}); diff --git a/shared/src/document-loader/document-loader.ts b/shared/src/document-loader/document-loader.ts index 0935ee7f4..a8d42d007 100644 --- a/shared/src/document-loader/document-loader.ts +++ b/shared/src/document-loader/document-loader.ts @@ -1,12 +1,5 @@ -import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; -import { SchemaDirectory } from '../schema-directory'; -import { CalmHubDocumentLoader } from './calmhub-document-loader'; -import { FileSystemDocumentLoader } from './file-system-document-loader'; -import { DirectUrlDocumentLoader } from './direct-url-document-loader'; -import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader'; -import { MappedDocumentLoader } from './mapped-document-loader'; -import { WorkspaceDocumentLoader } from './workspace-document-loader'; -import { AuthPlugin } from '..'; +import type { SchemaDirectory } from '../schema-directory.js'; +import type { AuthPlugin } from '../auth/auth-plugin.js'; import type { CalmDocumentType } from '@finos/calm-models/types'; export const CALM_HUB_PROTOS = ['http:', 'https:', 'calm:']; @@ -34,47 +27,6 @@ export type DocumentLoaderOptions = { workspaceBundlePath?: string; }; -export function buildDocumentLoader(docLoaderOpts: DocumentLoaderOptions): DocumentLoader { - const loaders = []; - const debug = docLoaderOpts.debug ?? false; - - // Workspace bundle takes top priority: local working copies override CalmHub and every - // other source, for any reference form (bare id, $id, versioned path, or full URL). - if (docLoaderOpts.workspaceBundlePath) { - loaders.push(new WorkspaceDocumentLoader(docLoaderOpts.workspaceBundlePath, debug)); - } - - // Add MappedDocumentLoader FIRST if mapping or basePath provided - // This ensures URL mappings are resolved before other loaders. - // Note: Relative paths are handled by FileSystemDocumentLoader later in the chain. - if ((docLoaderOpts.urlToLocalMap && docLoaderOpts.urlToLocalMap.size > 0) || docLoaderOpts.basePath) { - loaders.push(new MappedDocumentLoader( - docLoaderOpts.urlToLocalMap ?? new Map(), - docLoaderOpts.basePath ?? process.cwd(), - debug - )); - } - - if (docLoaderOpts.calmHubUrl) { - loaders.push(new CalmHubDocumentLoader(docLoaderOpts.calmHubUrl, debug, docLoaderOpts.authPlugin)); - } - - // Always configure FileSystemDocumentLoader with CALM_META_SCHEMA_DIRECTORY - const directoryPaths = [CALM_META_SCHEMA_DIRECTORY]; - if (docLoaderOpts.schemaDirectoryPath) { - directoryPaths.push(docLoaderOpts.schemaDirectoryPath); - } - loaders.push(new FileSystemDocumentLoader( - directoryPaths, - debug, - docLoaderOpts.basePath ?? process.cwd() - )); - - loaders.push(new DirectUrlDocumentLoader(debug, undefined, docLoaderOpts.allowedRemoteHosts)); - - return new MultiStrategyDocumentLoader(loaders, debug); -} - export function assertJsonObject(data: unknown, source: string): asserts data is object { if (typeof data !== 'object' || data === null || Array.isArray(data)) { const kind = data === null ? 'null' : Array.isArray(data) ? 'array' : typeof data; diff --git a/shared/src/document-loader/node-document-loader.spec.ts b/shared/src/document-loader/node-document-loader.spec.ts new file mode 100644 index 000000000..03d41d80d --- /dev/null +++ b/shared/src/document-loader/node-document-loader.spec.ts @@ -0,0 +1,201 @@ +import { AuthPlugin } from '../auth/auth-plugin'; +import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; +import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; +import { DocumentLoaderOptions } from './document-loader'; +import { buildDocumentLoader } from './node-document-loader'; + +const mocks = vi.hoisted(() => { + return { + fsDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + calmHubDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + mappedDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + directDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + workspaceDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }) + }; +}); + + +vi.mock('./file-system-document-loader', () => { + return { + FileSystemDocumentLoader: mocks.fsDocLoader + }; +}); + +vi.mock('./calmhub-document-loader', () => { + return { + CalmHubDocumentLoader: mocks.calmHubDocLoader + }; +}); + +vi.mock('./mapped-document-loader', () => { + return { + MappedDocumentLoader: mocks.mappedDocLoader + }; +}); + +vi.mock('./direct-url-document-loader', () => { + return { + DirectUrlDocumentLoader: mocks.directDocLoader + }; +}); + +vi.mock('./workspace-document-loader', () => { + return { + WorkspaceDocumentLoader: mocks.workspaceDocLoader + }; +}); + +describe('DocumentLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('should create a FileSystemDocumentLoader', () => { + + const docLoaderOpts: DocumentLoaderOptions = { + schemaDirectoryPath: 'schemas' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.fsDocLoader).toHaveBeenCalledWith([CALM_META_SCHEMA_DIRECTORY, 'schemas'], false, process.cwd()); + }); + + it('should not create a WorkspaceDocumentLoader when workspaceBundlePath is absent', () => { + buildDocumentLoader({ schemaDirectoryPath: 'schemas' }); + expect(mocks.workspaceDocLoader).not.toHaveBeenCalled(); + }); + + it('should create a WorkspaceDocumentLoader when workspaceBundlePath is provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + workspaceBundlePath: '/repo/.calm-workspace/bundles/default' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.workspaceDocLoader).toHaveBeenCalledWith('/repo/.calm-workspace/bundles/default', false); + }); + + it('should create a CalmHubDocumentLoader when calmHubUrl is defined in loader options', () => { + + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: 'https://example.com' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, undefined); + }); + + it('should pass authplugin to CalmHubDocumentLoader', () => { + + const mockAuthPlugin: AuthPlugin = { + getAuthHeaders: vi.fn() + }; + + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: 'https://example.com', + authPlugin: mockAuthPlugin + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, mockAuthPlugin); + }); + + it('should pass allowedRemoteHosts to DirectUrlDocumentLoader when provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + allowedRemoteHosts: ['schemas.example.com'] + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.directDocLoader).toHaveBeenCalledWith(false, undefined, ['schemas.example.com']); + }); + + it('should create a MappedDocumentLoader when urlToLocalMap is provided', () => { + const urlMap = new Map([ + ['https://example.com/schema.json', 'local/schema.json'] + ]); + + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: urlMap + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, process.cwd(), false); + }); + + it('should create a MappedDocumentLoader when basePath is provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + basePath: '/project/patterns' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(new Map(), '/project/patterns', false); + }); + + it('should create a MappedDocumentLoader with both urlToLocalMap and basePath', () => { + const urlMap = new Map([ + ['https://example.com/schema.json', 'local/schema.json'] + ]); + + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: urlMap, + basePath: '/custom/base' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, '/custom/base', false); + }); + + it('should not create a MappedDocumentLoader when neither urlToLocalMap nor basePath provided', () => { + const docLoaderOpts: DocumentLoaderOptions = {}; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); + }); + + it('should not create a MappedDocumentLoader when urlToLocalMap is empty and no basePath', () => { + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: new Map() + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); + }); +}); + +describe('isValidCalmDocumentType', () => { + it.each(CALM_DOCUMENT_TYPES_LIST)('returns true for the valid document type %s', (type) => { + expect(isValidCalmDocumentType(type)).toBe(true); + }); + + it.each(['unknown', 'architectures', 'Pattern', '', 'foo'])( + 'returns false for the invalid document type %s', + (type) => { + expect(isValidCalmDocumentType(type)).toBe(false); + } + ); +}); diff --git a/shared/src/document-loader/node-document-loader.ts b/shared/src/document-loader/node-document-loader.ts new file mode 100644 index 000000000..3d35e47ed --- /dev/null +++ b/shared/src/document-loader/node-document-loader.ts @@ -0,0 +1,49 @@ +import { CALM_META_SCHEMA_DIRECTORY } from '../consts.js'; +import { CalmHubDocumentLoader } from './calmhub-document-loader.js'; +import { FileSystemDocumentLoader } from './file-system-document-loader.js'; +import { DirectUrlDocumentLoader } from './direct-url-document-loader.js'; +import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader.js'; +import { MappedDocumentLoader } from './mapped-document-loader.js'; +import { WorkspaceDocumentLoader } from './workspace-document-loader.js'; +import type { DocumentLoader, DocumentLoaderOptions } from './document-loader.js'; + +export function buildDocumentLoader(docLoaderOpts: DocumentLoaderOptions): DocumentLoader { + const loaders = []; + const debug = docLoaderOpts.debug ?? false; + + // Workspace bundle takes top priority: local working copies override CalmHub and every + // other source, for any reference form (bare id, $id, versioned path, or full URL). + if (docLoaderOpts.workspaceBundlePath) { + loaders.push(new WorkspaceDocumentLoader(docLoaderOpts.workspaceBundlePath, debug)); + } + + // Add MappedDocumentLoader FIRST if mapping or basePath provided + // This ensures URL mappings are resolved before other loaders. + // Note: Relative paths are handled by FileSystemDocumentLoader later in the chain. + if ((docLoaderOpts.urlToLocalMap && docLoaderOpts.urlToLocalMap.size > 0) || docLoaderOpts.basePath) { + loaders.push(new MappedDocumentLoader( + docLoaderOpts.urlToLocalMap ?? new Map(), + docLoaderOpts.basePath ?? process.cwd(), + debug + )); + } + + if (docLoaderOpts.calmHubUrl) { + loaders.push(new CalmHubDocumentLoader(docLoaderOpts.calmHubUrl, debug, docLoaderOpts.authPlugin)); + } + + // Always configure FileSystemDocumentLoader with CALM_META_SCHEMA_DIRECTORY + const directoryPaths = [CALM_META_SCHEMA_DIRECTORY]; + if (docLoaderOpts.schemaDirectoryPath) { + directoryPaths.push(docLoaderOpts.schemaDirectoryPath); + } + loaders.push(new FileSystemDocumentLoader( + directoryPaths, + debug, + docLoaderOpts.basePath ?? process.cwd() + )); + + loaders.push(new DirectUrlDocumentLoader(debug, undefined, docLoaderOpts.allowedRemoteHosts)); + + return new MultiStrategyDocumentLoader(loaders, debug); +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 01cb84a2b..b2fcc3892 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -58,7 +58,8 @@ export { C4Model } from './docify/graphing/c4.js'; export { CalmRelationshipGraph } from './docify/graphing/relationship-graph.js'; export { ValidationOutcome } from './commands/validate/validation.output.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; -export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader.js'; +export { DocumentLoader, DocumentLoaderOptions, DocumentLoadError, assertJsonObject, CALM_HUB_PROTOS } from './document-loader/document-loader.js'; +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 * from './document-loader/loading-helpers.js'; diff --git a/shared/src/resolver/caching-tracking-resolver.ts b/shared/src/resolver/caching-tracking-resolver.ts index d68c51a16..d18093cab 100644 --- a/shared/src/resolver/caching-tracking-resolver.ts +++ b/shared/src/resolver/caching-tracking-resolver.ts @@ -1,4 +1,4 @@ -import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import type { CalmReferenceResolver } from './calm-reference-resolver.js'; /** * A caching, tracking {@link CalmReferenceResolver} decorator. diff --git a/shared/src/resolver/schema-directory-reference-resolver.ts b/shared/src/resolver/schema-directory-reference-resolver.ts index 673bec699..52a41cb52 100644 --- a/shared/src/resolver/schema-directory-reference-resolver.ts +++ b/shared/src/resolver/schema-directory-reference-resolver.ts @@ -1,5 +1,5 @@ import { SchemaDirectory } from '../schema-directory.js'; -import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import type { CalmReferenceResolver } from './calm-reference-resolver.js'; import type { CalmDocumentType } from '@finos/calm-models/types'; /** From ccd0ae9faa9a6ab81469e90355f00cd05b581878 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:28:21 +0100 Subject: [PATCH 04/25] feat(shared): add InMemoryDocumentLoader for browser and test consumers --- .../in-memory-document-loader.spec.ts | 42 +++++++++++++++++ .../in-memory-document-loader.ts | 45 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 shared/src/document-loader/in-memory-document-loader.spec.ts create mode 100644 shared/src/document-loader/in-memory-document-loader.ts diff --git a/shared/src/document-loader/in-memory-document-loader.spec.ts b/shared/src/document-loader/in-memory-document-loader.spec.ts new file mode 100644 index 000000000..18f4fbd59 --- /dev/null +++ b/shared/src/document-loader/in-memory-document-loader.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { InMemoryDocumentLoader } from './in-memory-document-loader'; +import { DocumentLoadError } from './document-loader'; +import { SchemaDirectory } from '../schema-directory'; + +const CORE = { $id: 'https://calm.finos.org/release/1.2/meta/core.json', $schema: 'https://json-schema.org/draft/2020-12/schema' }; +const ARCH = { 'unique-id': 'arch', nodes: [], relationships: [] }; + +describe('InMemoryDocumentLoader', () => { + it('stores every document with a $id into the schema directory on initialise', async () => { + const loader = new InMemoryDocumentLoader({ [CORE.$id]: CORE, 'https://x/arch.json': ARCH }); + const schemaDirectory = { storeDocument: vi.fn() } as unknown as SchemaDirectory; + await loader.initialise(schemaDirectory); + expect(schemaDirectory.storeDocument).toHaveBeenCalledTimes(1); + expect(schemaDirectory.storeDocument).toHaveBeenCalledWith(CORE.$id, 'schema', CORE); + }); + + it('serves documents by id regardless of type', async () => { + const loader = new InMemoryDocumentLoader({ 'https://x/arch.json': ARCH }); + await expect(loader.loadMissingDocument('https://x/arch.json', 'architecture')).resolves.toBe(ARCH); + }); + + it('throws a recoverable OPERATION_NOT_IMPLEMENTED error for unknown ids', async () => { + const loader = new InMemoryDocumentLoader({}); + await expect(loader.loadMissingDocument('https://x/missing.json', 'schema')).rejects.toMatchObject({ + name: 'OPERATION_NOT_IMPLEMENTED', + recoverable: true, + }); + await expect(loader.loadMissingDocument('https://x/missing.json', 'schema')).rejects.toBeInstanceOf(DocumentLoadError); + }); + + it('never resolves references to local paths', () => { + expect(new InMemoryDocumentLoader({}).resolvePath('./foo.json')).toBeUndefined(); + }); + + it('lets SchemaDirectory resolve a missing schema to undefined', async () => { + const schemaDirectory = new SchemaDirectory(new InMemoryDocumentLoader({ [CORE.$id]: CORE })); + await schemaDirectory.loadSchemas(); + expect(schemaDirectory.getLoadedSchemas()).toEqual([CORE.$id]); + await expect(schemaDirectory.getSchema('https://x/nope.json')).resolves.toBeUndefined(); + }); +}); diff --git a/shared/src/document-loader/in-memory-document-loader.ts b/shared/src/document-loader/in-memory-document-loader.ts new file mode 100644 index 000000000..ca21c2a10 --- /dev/null +++ b/shared/src/document-loader/in-memory-document-loader.ts @@ -0,0 +1,45 @@ +import type { SchemaDirectory } from '../schema-directory.js'; +import { DocumentLoader, DocumentLoadError } from './document-loader.js'; +import { initLogger, Logger } from '../logger.js'; +import type { CalmDocumentType } from '@finos/calm-models/types'; + +/** + * A {@link DocumentLoader} over a caller-supplied map of documentId -> document. Browser + * consumers mount their virtual filesystem (and the CALM meta-schemas they bundle) through + * this loader; it is also convenient for tests. + * + * Documents whose `$id` is a string are registered as schemas on initialise, so schema lookups + * behave exactly as they do with {@link FileSystemDocumentLoader} over a schema directory. + */ +export class InMemoryDocumentLoader implements DocumentLoader { + private readonly logger: Logger; + + constructor(private readonly documents: Record, debug: boolean = false) { + this.logger = initLogger(debug, 'in-memory-document-loader'); + } + + async initialise(schemaDirectory: SchemaDirectory): Promise { + for (const [key, document] of Object.entries(this.documents)) { + const id = (document as { $id?: unknown })['$id']; + if (typeof id !== 'string') { + this.logger.debug(`Skipping ${key}: no $id, not a schema.`); + continue; + } + schemaDirectory.storeDocument(id, 'schema', document); + this.logger.debug(`Registered schema ${id} from in-memory document ${key}.`); + } + } + + async loadMissingDocument(documentId: string, type: CalmDocumentType): Promise { + if (Object.prototype.hasOwnProperty.call(this.documents, documentId)) { + return this.documents[documentId]; + } + const message = `Document with id [${documentId}] and type [${type}] is not present in the in-memory document store.`; + this.logger.debug(message); + throw new DocumentLoadError({ name: 'OPERATION_NOT_IMPLEMENTED', message }); + } + + resolvePath(_reference: string): string | undefined { + return undefined; + } +} From 747c24c0bacaa6925df3b6fcf9d8a83601e68ce3 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:31:07 +0100 Subject: [PATCH 05/25] refactor(shared): replace net.isIP with a browser-safe IP literal check --- .../direct-url-document-loader.ts | 6 +-- shared/src/util/ip-literal.spec.ts | 13 +++++++ shared/src/util/ip-literal.ts | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 shared/src/util/ip-literal.spec.ts create mode 100644 shared/src/util/ip-literal.ts diff --git a/shared/src/document-loader/direct-url-document-loader.ts b/shared/src/document-loader/direct-url-document-loader.ts index 96671d7e5..fa5060e31 100644 --- a/shared/src/document-loader/direct-url-document-loader.ts +++ b/shared/src/document-loader/direct-url-document-loader.ts @@ -1,5 +1,5 @@ import axios, { Axios } from 'axios'; -import { isIP } from 'net'; +import { ipLiteralVersion } from '../util/ip-literal.js'; import { SchemaDirectory } from '../schema-directory'; import { DocumentLoader, DocumentLoadError, assertJsonObject } from './document-loader'; import { Logger, initLogger } from '../logger'; @@ -29,11 +29,11 @@ const SAFE_PATH_PATTERN = /^[a-zA-Z0-9/_.-]+$/; function isPrivateHost(hostname: string): boolean { if (/^localhost$/i.test(hostname)) return true; - // URL.hostname wraps IPv6 in brackets; strip them for isIP/pattern checks + // URL.hostname wraps IPv6 in brackets; strip them for ipLiteralVersion/pattern checks const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; - const ipVersion = isIP(bare); + const ipVersion = ipLiteralVersion(bare); if (ipVersion === 4) return PRIVATE_IPV4_PATTERNS.some(p => p.test(bare)); if (ipVersion === 6) return PRIVATE_IPV6_PATTERNS.some(p => p.test(bare)); return false; diff --git a/shared/src/util/ip-literal.spec.ts b/shared/src/util/ip-literal.spec.ts new file mode 100644 index 000000000..bb877f077 --- /dev/null +++ b/shared/src/util/ip-literal.spec.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { ipLiteralVersion } from './ip-literal'; + +describe('ipLiteralVersion', () => { + it.each([ + ['127.0.0.1', 4], ['10.0.0.1', 4], ['192.168.1.1', 4], ['255.255.255.255', 4], ['0.0.0.0', 4], + ['::1', 6], ['fe80::1', 6], ['fc00::', 6], ['2001:db8::ff00:42:8329', 6], ['::ffff:192.168.0.1', 6], + ['localhost', 0], ['calm.finos.org', 0], ['256.1.1.1', 0], ['1.2.3', 0], ['1.2.3.4.5', 0], + ['', 0], ['::g', 0], ['1234:5678', 0], ['1:2:3:4:5:6:7:8:9', 0], ['a::b::c', 0], ['::', 6], + ])('classifies %s as %s', (host, expected) => { + expect(ipLiteralVersion(host)).toBe(expected); + }); +}); diff --git a/shared/src/util/ip-literal.ts b/shared/src/util/ip-literal.ts new file mode 100644 index 000000000..dd9e35cca --- /dev/null +++ b/shared/src/util/ip-literal.ts @@ -0,0 +1,37 @@ +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const IPV4_TAIL = /(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const HEXTET = /^[0-9a-f]{1,4}$/i; + +/** + * Browser-safe replacement for Node's `net.isIP`: returns 4 for an IPv4 literal, 6 for an IPv6 + * literal, otherwise 0. Handles `::` compression and IPv4-mapped tails (`::ffff:1.2.3.4`). + */ +export function ipLiteralVersion(host: string): 0 | 4 | 6 { + if (IPV4.test(host)) { + return 4; + } + if (!host.includes(':')) { + return 0; + } + let candidate = host; + const mapped = candidate.match(IPV4_TAIL); + if (mapped && candidate.lastIndexOf(':') < (mapped.index ?? 0)) { + // IPv4-mapped tail counts as two hextets. + candidate = candidate.slice(0, mapped.index) + '0:0'; + } + const parts = candidate.split('::'); + if (parts.length > 2) { + return 0; + } + const groups = (segment: string) => (segment === '' ? [] : segment.split(':')); + const head = groups(parts[0]); + const tail = parts.length === 2 ? groups(parts[1]) : []; + if (![...head, ...tail].every((g) => HEXTET.test(g))) { + return 0; + } + const count = head.length + tail.length; + if (parts.length === 2) { + return count < 8 ? 6 : 0; + } + return count === 8 ? 6 : 0; +} From 582625949df59b6a83daee85884a24201cd47826 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:36:29 +0100 Subject: [PATCH 06/25] feat(shared): add buildBrowserDocumentLoader composing in-memory, hub and url strategies --- .../browser-document-loader.spec.ts | 46 +++++++++++++++++++ .../browser-document-loader.ts | 33 +++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 shared/src/document-loader/browser-document-loader.spec.ts create mode 100644 shared/src/document-loader/browser-document-loader.ts diff --git a/shared/src/document-loader/browser-document-loader.spec.ts b/shared/src/document-loader/browser-document-loader.spec.ts new file mode 100644 index 000000000..987115f20 --- /dev/null +++ b/shared/src/document-loader/browser-document-loader.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + inMemory: vi.fn(function () { return { kind: 'memory', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + calmHub: vi.fn(function () { return { kind: 'hub', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + directUrl: vi.fn(function () { return { kind: 'url', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + multi: vi.fn(function (loaders: unknown[]) { return { kind: 'multi', loaders }; }), +})); + +vi.mock('./in-memory-document-loader', () => ({ InMemoryDocumentLoader: mocks.inMemory })); +vi.mock('./calmhub-document-loader', () => ({ CalmHubDocumentLoader: mocks.calmHub })); +vi.mock('./direct-url-document-loader', () => ({ DirectUrlDocumentLoader: mocks.directUrl })); +vi.mock('./multi-strategy-document-loader', () => ({ MultiStrategyDocumentLoader: mocks.multi })); + +import { buildBrowserDocumentLoader } from './browser-document-loader'; + +describe('buildBrowserDocumentLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('composes in-memory then direct-url by default', () => { + const docs = { 'https://x/a.json': {} }; + const loader = buildBrowserDocumentLoader({ documents: docs }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.inMemory).toHaveBeenCalledWith(docs, false); + expect(mocks.calmHub).not.toHaveBeenCalled(); + expect(mocks.directUrl).toHaveBeenCalledWith(false, undefined, undefined); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory', 'url']); + }); + + it('inserts a CalmHub loader between memory and url when a hub url is given', () => { + const authPlugin = { getAuthHeaders: vi.fn() }; + const loader = buildBrowserDocumentLoader({ + documents: {}, calmHubUrl: 'https://hub', authPlugin: authPlugin as never, allowedRemoteHosts: ['calm.finos.org'], debug: true, + }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.calmHub).toHaveBeenCalledWith('https://hub', true, authPlugin); + expect(mocks.directUrl).toHaveBeenCalledWith(true, undefined, ['calm.finos.org']); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory', 'hub', 'url']); + }); + + it('omits the direct-url loader when allowRemote is false', () => { + const loader = buildBrowserDocumentLoader({ documents: {}, allowRemote: false }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.directUrl).not.toHaveBeenCalled(); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory']); + }); +}); diff --git a/shared/src/document-loader/browser-document-loader.ts b/shared/src/document-loader/browser-document-loader.ts new file mode 100644 index 000000000..f2774f0a8 --- /dev/null +++ b/shared/src/document-loader/browser-document-loader.ts @@ -0,0 +1,33 @@ +import type { AuthPlugin } from '../auth/auth-plugin.js'; +import type { DocumentLoader } from './document-loader.js'; +import { InMemoryDocumentLoader } from './in-memory-document-loader.js'; +import { CalmHubDocumentLoader } from './calmhub-document-loader.js'; +import { DirectUrlDocumentLoader } from './direct-url-document-loader.js'; +import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader.js'; + +export interface BrowserDocumentLoaderOptions { + /** documentId -> document. Entries with a string `$id` are registered as schemas. */ + documents: Record; + calmHubUrl?: string; + authPlugin?: AuthPlugin; + allowedRemoteHosts?: string[]; + /** Set false to disable direct HTTP(S) loading entirely. Default true. */ + allowRemote?: boolean; + debug?: boolean; +} + +/** + * Browser counterpart of `buildDocumentLoader`: no filesystem strategies, no `process.cwd()`. + * Order of precedence: in-memory documents, then CALM Hub (if configured), then direct URLs. + */ +export function buildBrowserDocumentLoader(opts: BrowserDocumentLoaderOptions): DocumentLoader { + const debug = opts.debug ?? false; + const loaders: DocumentLoader[] = [new InMemoryDocumentLoader(opts.documents, debug)]; + if (opts.calmHubUrl) { + loaders.push(new CalmHubDocumentLoader(opts.calmHubUrl, debug, opts.authPlugin)); + } + if (opts.allowRemote !== false) { + loaders.push(new DirectUrlDocumentLoader(debug, undefined, opts.allowedRemoteHosts)); + } + return new MultiStrategyDocumentLoader(loaders, debug); +} From 5cbc01ed2bcf591a034d2952c9eb390f3e1a0583 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:42:08 +0100 Subject: [PATCH 07/25] refactor(shared): register validation output formatters so junit stays out of the browser graph --- .../commands/validate/format-output.spec.ts | 30 ++++++++++ shared/src/commands/validate/format-output.ts | 44 ++++++++++++++ .../validate/output-formats/junit-output.ts | 14 +++++ .../validate/output-formats/pretty-output.ts | 14 +++-- shared/src/commands/validate/validate.ts | 60 ++++--------------- shared/src/index.ts | 3 + 6 files changed, 111 insertions(+), 54 deletions(-) create mode 100644 shared/src/commands/validate/format-output.spec.ts create mode 100644 shared/src/commands/validate/format-output.ts diff --git a/shared/src/commands/validate/format-output.spec.ts b/shared/src/commands/validate/format-output.spec.ts new file mode 100644 index 000000000..068d190a6 --- /dev/null +++ b/shared/src/commands/validate/format-output.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ValidationOutcome } from './validation.output'; + +describe('formatOutput registry', () => { + beforeEach(() => vi.resetModules()); + + const outcome = new ValidationOutcome([], [], false, false); + + it('formats json and pretty without any registration', async () => { + const { formatOutput } = await import('./format-output'); + expect(JSON.parse(formatOutput(outcome, 'json'))).toMatchObject({ hasErrors: false }); + expect(formatOutput(outcome, 'pretty')).toContain('No issues found'); + }); + + it('throws a clear error for an unregistered format', async () => { + const { formatOutput } = await import('./format-output'); + expect(() => formatOutput(outcome, 'junit')).toThrow(/junit.*not available/i); + }); + + it('uses a registered formatter', async () => { + const { formatOutput, registerOutputFormatter } = await import('./format-output'); + registerOutputFormatter('junit', () => ''); + expect(formatOutput(outcome, 'junit')).toBe(''); + }); + + it('the root barrel registers junit', async () => { + const { getFormattedOutput } = await import('../../index'); + expect(getFormattedOutput(outcome, 'junit')).toContain('; +} + +export type OutputFormatter = (outcome: ValidationOutcome, options?: ValidationFormattingOptions) => string; + +const formatters = new Map([ + ['json', (outcome) => prettifyJson(outcome)], + ['pretty', (outcome, options) => prettyFormat(outcome, options)], +]); + +/** + * Registers (or replaces) the formatter for an output format. The root entry point registers + * `junit`, which depends on a Node-oriented XML builder; the browser entry ships json + pretty. + */ +export function registerOutputFormatter(format: OutputFormat, formatter: OutputFormatter): void { + formatters.set(format, formatter); +} + +export function formatOutput( + validationOutcome: ValidationOutcome, + format: OutputFormat, + options?: ValidationFormattingOptions +): string { + const formatter = formatters.get(format); + if (!formatter) { + throw new Error(`Output format '${format}' is not available in this environment. Available: ${[...formatters.keys()].join(', ')}`); + } + return formatter(validationOutcome, options); +} diff --git a/shared/src/commands/validate/output-formats/junit-output.ts b/shared/src/commands/validate/output-formats/junit-output.ts index 1d07463fb..4f709e16d 100644 --- a/shared/src/commands/validate/output-formats/junit-output.ts +++ b/shared/src/commands/validate/output-formats/junit-output.ts @@ -1,5 +1,9 @@ import junitReportBuilder, { TestSuite } from 'junit-report-builder'; +import { RulesetDefinition } from '@stoplight/spectral-core'; import { ValidationOutcome } from '../validation.output'; +import validationRulesForPattern from '../../../spectral/rules-pattern.js'; +import validationRulesForArchitecture from '../../../spectral/rules-architecture.js'; +import type { OutputFormatter } from '../format-output.js'; export default function createJUnitReport( validationOutcome: ValidationOutcome, @@ -52,3 +56,13 @@ function createFailingTestCase(testSuite: TestSuite, testName: string){ .failure(); } +function getRuleNamesFromRuleset(ruleset: RulesetDefinition): string[] { + return Object.keys((ruleset as { rules: Record }).rules); +} + +function extractSpectralRuleNames(): string[] { + return getRuleNamesFromRuleset(validationRulesForArchitecture) + .concat(getRuleNamesFromRuleset(validationRulesForPattern)); +} + +export const junitFormatter: OutputFormatter = (outcome) => createJUnitReport(outcome, extractSpectralRuleNames()); diff --git a/shared/src/commands/validate/output-formats/pretty-output.ts b/shared/src/commands/validate/output-formats/pretty-output.ts index a6f3a6a5b..3ca9eb16e 100644 --- a/shared/src/commands/validate/output-formats/pretty-output.ts +++ b/shared/src/commands/validate/output-formats/pretty-output.ts @@ -1,13 +1,14 @@ -import path from 'path'; import { ValidationOutcome, ValidationOutput } from '../validation.output.js'; -import { ValidationFormattingOptions, ValidationDocumentContext } from '../validate.js'; +import type { ValidationFormattingOptions, ValidationDocumentContext } from '../format-output.js'; type Severity = 'error' | 'warning' | 'info' | 'hint' | string; const severityOrder: Severity[] = ['error', 'warning', 'info', 'hint']; -const supportsColor = Boolean(process?.stdout?.isTTY) && process.env.NO_COLOR !== '1'; +const supportsColor = typeof process !== 'undefined' + && Boolean(process.stdout?.isTTY) + && process.env?.NO_COLOR !== '1'; const colors = { red: (text: string) => (supportsColor ? `\u001b[31m${text}\u001b[0m` : text), @@ -32,6 +33,11 @@ const severityColor: Partial string>> = { hint: colors.gray }; +function basename(filePath: string): string { + const segments = filePath.split(/[\\/]/).filter(Boolean); + return segments.length ? segments[segments.length - 1] : filePath; +} + function formatSeverity(severity: Severity): string { const label = severityLabel[severity] ?? (severity ? severity.toUpperCase() : 'ISSUE'); const color = severityColor[severity] ?? ((text: string) => text); @@ -92,7 +98,7 @@ function buildDocumentHeader(documentId: string, context?: ValidationDocumentCon if (!context) { return `- In ${documentId || 'document'}:`; } - const label = context.label || path.basename(context.filePath || context.id || ''); + const label = context.label || basename(context.filePath || context.id || ''); const location = context.filePath ? ` (${context.filePath})` : ''; return `- In ${label}${location}:`; } diff --git a/shared/src/commands/validate/validate.ts b/shared/src/commands/validate/validate.ts index 524487c78..285ee4839 100644 --- a/shared/src/commands/validate/validate.ts +++ b/shared/src/commands/validate/validate.ts @@ -1,15 +1,8 @@ -import { RulesetDefinition } from '@stoplight/spectral-core'; - -import validationRulesForPattern from '../../spectral/rules-pattern.js'; -import validationRulesForArchitecture from '../../spectral/rules-architecture.js'; import { initLogger, Logger } from '../../logger.js'; import { ValidationOutcome } from './validation.output.js'; -import createJUnitReport from './output-formats/junit-output.js'; -import prettyFormat from './output-formats/pretty-output.js'; import { SchemaDirectory } from '../../schema-directory.js'; import { ValidationContext, ValidationMode } from './validation-rule.js'; import { createDefaultValidationEngine, ValidationEngine } from './validation-engine.js'; -import { prettifyJson } from './validation-helpers.js'; import { CachingTrackingResolver } from '../../resolver/caching-tracking-resolver.js'; import { SchemaDirectoryReferenceResolver } from '../../resolver/schema-directory-reference-resolver.js'; @@ -23,20 +16,17 @@ export { convertSpectralDiagnosticToValidationOutputs } from './validation-helpers.js'; -let logger: Logger; // defined later at startup - -export type ValidateOutputFormat = 'json' | 'junit' | 'pretty'; - -export interface ValidationDocumentContext { - id: string; - label?: string; - filePath?: string; - lines?: string[]; -} +export { + formatOutput, + registerOutputFormatter, + type OutputFormat, + type ValidateOutputFormat, + type ValidationDocumentContext, + type ValidationFormattingOptions, + type OutputFormatter, +} from './format-output.js'; -export interface ValidationFormattingOptions { - documents?: Record; -} +let logger: Logger; // defined later at startup /** * TODO - move this out of shared and into the CLI - this is process-management code. @@ -54,26 +44,6 @@ export function exitBasedOffOfValidationOutcome(validationOutcome: ValidationOut process.exit(0); } -export type OutputFormat = 'junit' | 'json' | 'pretty' - -export function formatOutput( - validationOutcome: ValidationOutcome, - format: OutputFormat, - options?: ValidationFormattingOptions -): string { - logger.info(`Formatting output as ${format}`); - switch (format) { - case 'junit': { - const spectralRuleNames = extractSpectralRuleNames(); - return createJUnitReport(validationOutcome, spectralRuleNames); - } - case 'pretty': - return prettyFormat(validationOutcome, options); - case 'json': - return prettifyJson(validationOutcome); - } -} - /** * Asserts that a schema directory was provided. Validating a pattern, a timeline, * or an architecture against a pattern all require schema resolution, so a missing @@ -158,13 +128,3 @@ function buildValidationContext( logger.debug('You must provide an architecture, a pattern, or a timeline'); throw new Error('You must provide an architecture, a pattern, or a timeline'); } - -function extractSpectralRuleNames(): string[] { - const architectureRuleNames = getRuleNamesFromRuleset(validationRulesForArchitecture); - const patternRuleNames = getRuleNamesFromRuleset(validationRulesForPattern); - return architectureRuleNames.concat(patternRuleNames); -} - -function getRuleNamesFromRuleset(ruleset: RulesetDefinition): string[] { - return Object.keys((ruleset as { rules: Record }).rules); -} diff --git a/shared/src/index.ts b/shared/src/index.ts index b2fcc3892..c2f2cf33f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,7 +1,10 @@ import { registerNodeLoggerFactory } from './logger.js'; import { createWinstonLogger } from './logger.node.js'; +import { registerOutputFormatter } from './commands/validate/format-output.js'; +import { junitFormatter } from './commands/validate/output-formats/junit-output.js'; registerNodeLoggerFactory(createWinstonLogger); +registerOutputFormatter('junit', junitFormatter); export { validate, From 7facd75fd71a63661336f0442132164be1e74fdb Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:49:40 +0100 Subject: [PATCH 08/25] refactor(shared): split a pure generate core from the file-writing runGenerate --- shared/src/commands/generate/generate-core.ts | 22 ++++++++++++ shared/src/commands/generate/generate.spec.ts | 35 +++++++++++++++++++ shared/src/commands/generate/generate.ts | 25 ++++--------- 3 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 shared/src/commands/generate/generate-core.ts diff --git a/shared/src/commands/generate/generate-core.ts b/shared/src/commands/generate/generate-core.ts new file mode 100644 index 000000000..94ca661f6 --- /dev/null +++ b/shared/src/commands/generate/generate-core.ts @@ -0,0 +1,22 @@ +import { CalmChoice, selectChoices } from './components/options.js'; +import { instantiate } from './components/instantiate'; +import { flattenAllOf } from './components/flatten-allof'; +import { SchemaDirectory } from '../../schema-directory.js'; + +export interface GenerateOptions { + debug?: boolean; + chosenChoices?: CalmChoice[]; +} + +/** + * Instantiate an architecture from a pattern. Pure: no filesystem access, errors propagate. + */ +export async function generate(pattern: object, schemaDirectory: SchemaDirectory, options: GenerateOptions = {}): Promise { + const debug = options.debug ?? false; + await schemaDirectory.loadSchemas(); + let flattenedPattern = await flattenAllOf(pattern as Record, schemaDirectory, debug); + if (options.chosenChoices) { + flattenedPattern = selectChoices(flattenedPattern, options.chosenChoices, debug); + } + return instantiate(flattenedPattern, debug, schemaDirectory) as Promise; +} diff --git a/shared/src/commands/generate/generate.spec.ts b/shared/src/commands/generate/generate.spec.ts index 65a265bac..a2db3f338 100644 --- a/shared/src/commands/generate/generate.spec.ts +++ b/shared/src/commands/generate/generate.spec.ts @@ -87,3 +87,38 @@ describe('runGenerate', () => { }); }); + +describe('generate core', () => { + // The module-level vi.mock calls above replace instantiate/flattenAllOf for the whole file. + // These tests need the real implementations, so unmock + reset modules before each dynamic import. + beforeEach(() => { + vi.doUnmock('./components/instantiate'); + vi.doUnmock('./components/flatten-allof'); + vi.resetModules(); + }); + + it('returns the instantiated architecture object without touching the filesystem', async () => { + const { generate } = await import('./generate-core'); + const pattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://x/pattern.json', + type: 'object', + properties: { + nodes: { type: 'array', prefixItems: [ + { type: 'object', properties: { 'unique-id': { const: 'a' }, 'node-type': { const: 'service' }, name: { const: 'A' }, description: { const: 'd' } } } + ] }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + const schemaDirectory = { loadSchemas: vi.fn(), getSchema: vi.fn(), getDefinition: vi.fn(), loadCurrentPatternAsSchema: vi.fn() } as unknown as SchemaDirectory; + const result = await generate(pattern, schemaDirectory) as { nodes: { 'unique-id': string }[] }; + expect(schemaDirectory.loadSchemas).toHaveBeenCalled(); + expect(result.nodes[0]['unique-id']).toBe('a'); + }); + + it('propagates errors instead of swallowing them', async () => { + const { generate } = await import('./generate-core'); + const schemaDirectory = { loadSchemas: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as SchemaDirectory; + await expect(generate({}, schemaDirectory)).rejects.toThrow('boom'); + }); +}); diff --git a/shared/src/commands/generate/generate.ts b/shared/src/commands/generate/generate.ts index 15ce20e9d..439fc4f49 100644 --- a/shared/src/commands/generate/generate.ts +++ b/shared/src/commands/generate/generate.ts @@ -2,33 +2,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { mkdirp } from 'mkdirp'; -import { CalmChoice, selectChoices } from './components/options.js'; -import { instantiate } from './components/instantiate'; -import { flattenAllOf } from './components/flatten-allof'; +import { CalmChoice } from './components/options.js'; import { initLogger } from '../../logger.js'; import { SchemaDirectory } from '../../schema-directory.js'; +import { generate } from './generate-core.js'; + +export { generate, type GenerateOptions } from './generate-core.js'; export async function runGenerate(pattern: object, outputPath: string, debug: boolean, schemaDirectory: SchemaDirectory, chosenChoices?: CalmChoice[]): Promise { const logger = initLogger(debug, 'calm-generate'); logger.info('Generating a CALM architecture...'); try { - // Flatten any allOf compositions before processing - await schemaDirectory.loadSchemas(); - let flattenedPattern = await flattenAllOf( - pattern as Record, - schemaDirectory, - debug - ); - - if (chosenChoices) { - flattenedPattern = selectChoices(flattenedPattern, chosenChoices, debug); - } - - const final = await instantiate(flattenedPattern, debug, schemaDirectory); + const final = await generate(pattern, schemaDirectory, { debug, chosenChoices }); const output = JSON.stringify(final, null, 2); - const dirname = path.dirname(outputPath); - - mkdirp.sync(dirname); + mkdirp.sync(path.dirname(outputPath)); fs.writeFileSync(outputPath, output); logger.info(`Successfully generated architecture to [${outputPath}]`); } catch (err) { From 959729890ae5c66039000a9afae00e57e2aad4a8 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:56:15 +0100 Subject: [PATCH 09/25] refactor(shared): split pure diff and timeline cores from the file-based runners --- shared/src/commands/diff/diff-core.ts | 244 +++++++++++++++++++++++ shared/src/commands/diff/diff.spec.ts | 35 ++++ shared/src/commands/diff/diff.ts | 274 +++----------------------- 3 files changed, 302 insertions(+), 251 deletions(-) create mode 100644 shared/src/commands/diff/diff-core.ts diff --git a/shared/src/commands/diff/diff-core.ts b/shared/src/commands/diff/diff-core.ts new file mode 100644 index 000000000..b8da9efc1 --- /dev/null +++ b/shared/src/commands/diff/diff-core.ts @@ -0,0 +1,244 @@ +import { + diffArchitectures, + diffPatterns, + diffTimelineAdjacent, + diffTimelineMoments, + type ArchitectureResolver, + type MomentDiff, + type NodesAndRelationshipsDiffResult, + type TimelineInput, +} from '@finos/calm-models/diff'; +import type { CalmArchitectureSchema, CalmNodeSchema, CalmRelationshipSchema } from '@finos/calm-models/types'; +import { initLogger } from '../../logger.js'; + +export type DiffOutputFormat = 'json' | 'summary'; + +export type DiffDocumentType = 'architecture' | 'pattern'; + +export interface DiffRunResult { + diff: NodesAndRelationshipsDiffResult; + formatted: string; + hasChanges: boolean; +} + +export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { + return ( + diff.nodesAdded.length > 0 || + diff.nodesRemoved.length > 0 || + diff.nodesModified.length > 0 || + diff.nodesRenamed.length > 0 || + diff.edgesAdded.length > 0 || + diff.edgesRemoved.length > 0 || + diff.edgesModified.length > 0 || + diff.edgesRenamed.length > 0 || + (diff.invalidItems?.nodes.length ?? 0) > 0 || + (diff.invalidItems?.relationships.length ?? 0) > 0 || + (diff.undiffableItems?.nodes.length ?? 0) > 0 || + (diff.undiffableItems?.relationships.length ?? 0) > 0 + ); +} + +/** + * Label for a node/relationship in the summary view. Falls back to a content + * hint for pattern items that have no pinned `unique-id`, so they don't render + * as `undefined`. + */ +function nodeLabel(node: CalmNodeSchema): string { + const item = node as Record; + if (typeof item['unique-id'] === 'string') return item['unique-id']; + const detail = [item['node-type'], item['name']].filter((v) => typeof v === 'string').join(' '); + return detail ? `(unpinned ${detail})` : '(unpinned node)'; +} + +function edgeLabel(edge: CalmRelationshipSchema): string { + const item = edge as Record; + return typeof item['unique-id'] === 'string' ? item['unique-id'] : '(unpinned relationship)'; +} + +export function formatDiff( + diff: NodesAndRelationshipsDiffResult, + format: DiffOutputFormat, + documentType: DiffDocumentType = 'architecture', +): string { + if (format === 'json') { + return JSON.stringify(diff, null, 2); + } + const invalidNodes = diff.invalidItems?.nodes.length ?? 0; + const invalidEdges = diff.invalidItems?.relationships.length ?? 0; + const undiffableNodes = diff.undiffableItems?.nodes.length ?? 0; + const undiffableEdges = diff.undiffableItems?.relationships.length ?? 0; + const title = `CALM ${documentType} diff`; + const lines = [ + title, + '-'.repeat(title.length), + `Nodes: +${diff.nodesAdded.length} -${diff.nodesRemoved.length} ~${diff.nodesModified.length} ↔${diff.nodesRenamed.length} =${diff.nodesSame.length}`, + `Relationships: +${diff.edgesAdded.length} -${diff.edgesRemoved.length} ~${diff.edgesModified.length} ↔${diff.edgesRenamed.length} =${diff.edgesSame.length}`, + ]; + if (invalidNodes + invalidEdges > 0) { + lines.push(`Invalid items: ${invalidNodes} node(s) + ${invalidEdges} relationship(s) skipped (missing unique-id)`); + } + if (undiffableNodes + undiffableEdges > 0) { + lines.push(`Undiffable items: ${undiffableNodes} node(s) + ${undiffableEdges} relationship(s) (no constrained unique-id to diff by)`); + } + lines.push(''); + const list = (label: string, ids: string[]) => { + if (ids.length === 0) return; + lines.push(label); + for (const id of ids) lines.push(` - ${id}`); + lines.push(''); + }; + list('Nodes added:', diff.nodesAdded.map(nodeLabel)); + list('Nodes removed:', diff.nodesRemoved.map(nodeLabel)); + list('Nodes modified:', diff.nodesModified.map((n) => nodeLabel(n.original))); + list('Nodes renamed:', diff.nodesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); + list('Relationships added:', diff.edgesAdded.map(edgeLabel)); + list('Relationships removed:', diff.edgesRemoved.map(edgeLabel)); + list('Relationships modified:', diff.edgesModified.map((e) => edgeLabel(e.original))); + list('Relationships renamed:', diff.edgesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); + return lines.join('\n'); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function looksLikePattern(doc: Record): boolean { + const hasNodeOrRelProps = (schema: Record): boolean => { + const props = isObject(schema['properties']) ? schema['properties'] : undefined; + return !!props && (isObject(props['nodes']) || isObject(props['relationships'])); + }; + if (hasNodeOrRelProps(doc)) return true; + return Array.isArray(doc['allOf']) + && doc['allOf'].some((sub) => isObject(sub) && hasNodeOrRelProps(sub)); +} + +/** + * Classifies a document as an architecture instance (top-level + * `nodes`/`relationships` arrays) or a pattern (a JSON Schema describing those + * arrays under `properties`/`allOf`). Returns `null` when the input clearly + * matches neither shape, leaving the decision to the caller. + */ +export function tryDetectDocumentType(doc: Record): DiffDocumentType | null { + if (Array.isArray(doc['nodes']) || Array.isArray(doc['relationships'])) { + return 'architecture'; + } + if (looksLikePattern(doc)) { + return 'pattern'; + } + return null; +} + +/** + * Like {@link tryDetectDocumentType} but throws when the input matches neither + * shape, so malformed input is surfaced rather than silently diffed to an empty + * result. + */ +export function detectDocumentType(doc: Record): DiffDocumentType { + const detected = tryDetectDocumentType(doc); + if (detected) { + return detected; + } + throw new Error( + 'Could not determine the CALM document type: expected an architecture ' + + '(top-level nodes/relationships arrays) or a pattern (a JSON Schema ' + + 'describing them). Pass --type architecture|pattern to specify it explicitly.', + ); +} + +export interface TimelineDiffRunOptions { + /** Diff only this single pair instead of all adjacent pairs. */ + fromMomentId?: string; + toMomentId?: string; + verbose?: boolean; +} + +export interface TimelineDiffRunResult { + /** Ordered diffs: one per adjacent pair, or a single entry for an explicit pair. */ + diffs: MomentDiff[]; +} + +export interface DiffDocumentsOptions { + format?: DiffOutputFormat; + verbose?: boolean; + /** Override automatic architecture/pattern detection. */ + documentType?: DiffDocumentType; + /** Names used for the two documents in messages (e.g. file paths). */ + labels?: [string, string]; +} + +export function diffDocuments( + docA: Record, + docB: Record, + options: DiffDocumentsOptions = {}, +): DiffRunResult { + const logger = initLogger(!!options.verbose, 'calm-diff'); + const format = options.format ?? 'json'; + const [labelA, labelB] = options.labels ?? ['document A', 'document B']; + + let documentType: DiffDocumentType; + if (options.documentType) { + documentType = options.documentType; + for (const [label, doc] of [[labelA, docA], [labelB, docB]] as const) { + const detected = tryDetectDocumentType(doc); + if (detected && detected !== documentType) { + throw new Error( + `--type was set to '${documentType}', but ${label} matches '${detected}'. ` + + 'Remove --type to auto-detect, or pass inputs of the forced type.', + ); + } + } + } else { + const typeA = detectDocumentType(docA); + const typeB = detectDocumentType(docB); + if (typeA !== typeB) { + throw new Error( + `Cannot diff mismatched document types: ${typeA} vs ${typeB}. Both inputs must be the ` + + 'same CALM document type; pass --type to override detection.', + ); + } + documentType = typeA; + } + + const diff = documentType === 'pattern' + ? diffPatterns(docA, docB) + : diffArchitectures(docA as CalmArchitectureSchema, docB as CalmArchitectureSchema); + + const invalidNodeCount = diff.invalidItems?.nodes.length ?? 0; + const invalidEdgeCount = diff.invalidItems?.relationships.length ?? 0; + if (invalidNodeCount + invalidEdgeCount > 0) { + logger.warn( + `Skipped ${invalidNodeCount} node(s) and ${invalidEdgeCount} relationship(s) ` + + 'because they were missing a unique-id. These items are reported under ' + + 'invalidItems and contribute to hasChanges so --exit-code does not pass on them silently.', + ); + } + const undiffableNodeCount = diff.undiffableItems?.nodes.length ?? 0; + const undiffableEdgeCount = diff.undiffableItems?.relationships.length ?? 0; + if (undiffableNodeCount + undiffableEdgeCount > 0) { + logger.warn( + `Could not diff ${undiffableNodeCount} node(s) and ${undiffableEdgeCount} relationship(s) ` + + 'because they constrain no comparable content (e.g. an unconstrained unique-id). ' + + 'These items are reported under undiffableItems and contribute to hasChanges so ' + + '--exit-code does not pass on them silently.', + ); + } + + const formatted = formatDiff(diff, format, documentType); + return { diff, formatted, hasChanges: hasChanges(diff) }; +} + +export async function diffTimeline( + timeline: TimelineInput, + resolver: ArchitectureResolver, + options: TimelineDiffRunOptions = {}, +): Promise { + if (options.fromMomentId || options.toMomentId) { + if (!options.fromMomentId || !options.toMomentId) { + throw new Error('Both fromMomentId and toMomentId must be supplied to diff a specific pair.'); + } + const diff = await diffTimelineMoments(timeline, options.fromMomentId, options.toMomentId, resolver); + return { diffs: [diff] }; + } + const diffs = await diffTimelineAdjacent(timeline, resolver); + return { diffs }; +} diff --git a/shared/src/commands/diff/diff.spec.ts b/shared/src/commands/diff/diff.spec.ts index b2013a0dc..9a9dfa54d 100644 --- a/shared/src/commands/diff/diff.spec.ts +++ b/shared/src/commands/diff/diff.spec.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import path from 'node:path'; import { runDiff, formatDiff, hasChanges, detectDocumentType } from './diff.js'; import type { NodesAndRelationshipsDiffResult } from '@finos/calm-models/diff'; +import type { TimelineInput } from '@finos/calm-models/diff'; const loggerMock = { info: vi.fn(), @@ -313,3 +314,37 @@ describe('formatDiff', () => { expect(out).not.toContain('undefined'); }); }); + +describe('diff core', () => { + const archA = { nodes: [{ 'unique-id': 'a', 'node-type': 'service', name: 'A', description: 'x' }], relationships: [] }; + const archB = { nodes: [...archA.nodes, { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: 'y' }], relationships: [] }; + + it('diffs two architecture objects and formats a summary', async () => { + const { diffDocuments } = await import('./diff-core'); + const result = diffDocuments(archA, archB, { format: 'summary' }); + expect(result.hasChanges).toBe(true); + expect(result.diff.nodesAdded.map((n) => n['unique-id'])).toEqual(['b']); + expect(result.formatted).toContain('Nodes added:'); + }); + + it('uses labels in the mismatch error instead of file paths', async () => { + const { diffDocuments } = await import('./diff-core'); + expect(() => diffDocuments(archA, archB, { documentType: 'pattern', labels: ['left.json', 'right.json'] })) + .toThrow(/left\.json matches 'architecture'/); + }); + + it('diffs a timeline through an injected resolver', async () => { + const { diffTimeline } = await import('./diff-core'); + const timeline = { + 'unique-id': 't', + moments: [ + { 'unique-id': 'm1', details: { 'detailed-architecture': 'mem://a' } }, + { 'unique-id': 'm2', details: { 'detailed-architecture': 'mem://b' } }, + ], + } as unknown as TimelineInput; + const resolver = vi.fn(async (ref: string) => (ref === 'mem://a' ? archA : archB)); + const { diffs } = await diffTimeline(timeline, resolver); + expect(resolver).toHaveBeenCalledWith('mem://a'); + expect(diffs).toHaveLength(1); + }); +}); diff --git a/shared/src/commands/diff/diff.ts b/shared/src/commands/diff/diff.ts index 0da9dc6ac..b17b75e87 100644 --- a/shared/src/commands/diff/diff.ts +++ b/shared/src/commands/diff/diff.ts @@ -1,22 +1,19 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { mkdirp } from 'mkdirp'; -import { - diffArchitectures, - diffPatterns, - diffTimelineAdjacent, - diffTimelineMoments, - type ArchitectureResolver, - type MomentDiff, - type NodesAndRelationshipsDiffResult, - type TimelineInput, -} from '@finos/calm-models/diff'; -import type { CalmArchitectureSchema, CalmNodeSchema, CalmRelationshipSchema } from '@finos/calm-models/types'; +import type { ArchitectureResolver, TimelineInput } from '@finos/calm-models/diff'; import { initLogger } from '../../logger.js'; +import { + diffDocuments, + diffTimeline, + type DiffDocumentType, + type DiffOutputFormat, + type DiffRunResult, + type TimelineDiffRunOptions, + type TimelineDiffRunResult, +} from './diff-core.js'; -export type DiffOutputFormat = 'json' | 'summary'; - -export type DiffDocumentType = 'architecture' | 'pattern'; +export * from './diff-core.js'; export interface DiffRunOptions { format?: DiffOutputFormat; @@ -26,229 +23,28 @@ export interface DiffRunOptions { documentType?: DiffDocumentType; } -export interface DiffRunResult { - diff: NodesAndRelationshipsDiffResult; - formatted: string; - hasChanges: boolean; -} - -export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { - return ( - diff.nodesAdded.length > 0 || - diff.nodesRemoved.length > 0 || - diff.nodesModified.length > 0 || - diff.nodesRenamed.length > 0 || - diff.edgesAdded.length > 0 || - diff.edgesRemoved.length > 0 || - diff.edgesModified.length > 0 || - diff.edgesRenamed.length > 0 || - (diff.invalidItems?.nodes.length ?? 0) > 0 || - (diff.invalidItems?.relationships.length ?? 0) > 0 || - (diff.undiffableItems?.nodes.length ?? 0) > 0 || - (diff.undiffableItems?.relationships.length ?? 0) > 0 - ); -} - -/** - * Label for a node/relationship in the summary view. Falls back to a content - * hint for pattern items that have no pinned `unique-id`, so they don't render - * as `undefined`. - */ -function nodeLabel(node: CalmNodeSchema): string { - const item = node as Record; - if (typeof item['unique-id'] === 'string') return item['unique-id']; - const detail = [item['node-type'], item['name']].filter((v) => typeof v === 'string').join(' '); - return detail ? `(unpinned ${detail})` : '(unpinned node)'; -} - -function edgeLabel(edge: CalmRelationshipSchema): string { - const item = edge as Record; - return typeof item['unique-id'] === 'string' ? item['unique-id'] : '(unpinned relationship)'; -} - -export function formatDiff( - diff: NodesAndRelationshipsDiffResult, - format: DiffOutputFormat, - documentType: DiffDocumentType = 'architecture', -): string { - if (format === 'json') { - return JSON.stringify(diff, null, 2); - } - const invalidNodes = diff.invalidItems?.nodes.length ?? 0; - const invalidEdges = diff.invalidItems?.relationships.length ?? 0; - const undiffableNodes = diff.undiffableItems?.nodes.length ?? 0; - const undiffableEdges = diff.undiffableItems?.relationships.length ?? 0; - const title = `CALM ${documentType} diff`; - const lines = [ - title, - '-'.repeat(title.length), - `Nodes: +${diff.nodesAdded.length} -${diff.nodesRemoved.length} ~${diff.nodesModified.length} ↔${diff.nodesRenamed.length} =${diff.nodesSame.length}`, - `Relationships: +${diff.edgesAdded.length} -${diff.edgesRemoved.length} ~${diff.edgesModified.length} ↔${diff.edgesRenamed.length} =${diff.edgesSame.length}`, - ]; - if (invalidNodes + invalidEdges > 0) { - lines.push(`Invalid items: ${invalidNodes} node(s) + ${invalidEdges} relationship(s) skipped (missing unique-id)`); - } - if (undiffableNodes + undiffableEdges > 0) { - lines.push(`Undiffable items: ${undiffableNodes} node(s) + ${undiffableEdges} relationship(s) (no constrained unique-id to diff by)`); - } - lines.push(''); - const list = (label: string, ids: string[]) => { - if (ids.length === 0) return; - lines.push(label); - for (const id of ids) lines.push(` - ${id}`); - lines.push(''); - }; - list('Nodes added:', diff.nodesAdded.map(nodeLabel)); - list('Nodes removed:', diff.nodesRemoved.map(nodeLabel)); - list('Nodes modified:', diff.nodesModified.map((n) => nodeLabel(n.original))); - list('Nodes renamed:', diff.nodesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); - list('Relationships added:', diff.edgesAdded.map(edgeLabel)); - list('Relationships removed:', diff.edgesRemoved.map(edgeLabel)); - list('Relationships modified:', diff.edgesModified.map((e) => edgeLabel(e.original))); - list('Relationships renamed:', diff.edgesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); - return lines.join('\n'); -} - function readDocument(filePath: string): Record { const resolved = path.resolve(filePath); const raw = fs.readFileSync(resolved, 'utf-8'); return JSON.parse(raw) as Record; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function looksLikePattern(doc: Record): boolean { - const hasNodeOrRelProps = (schema: Record): boolean => { - const props = isObject(schema['properties']) ? schema['properties'] : undefined; - return !!props && (isObject(props['nodes']) || isObject(props['relationships'])); - }; - if (hasNodeOrRelProps(doc)) return true; - return Array.isArray(doc['allOf']) - && doc['allOf'].some((sub) => isObject(sub) && hasNodeOrRelProps(sub)); -} - -/** - * Classifies a document as an architecture instance (top-level - * `nodes`/`relationships` arrays) or a pattern (a JSON Schema describing those - * arrays under `properties`/`allOf`). Returns `null` when the input clearly - * matches neither shape, leaving the decision to the caller. - */ -export function tryDetectDocumentType(doc: Record): DiffDocumentType | null { - if (Array.isArray(doc['nodes']) || Array.isArray(doc['relationships'])) { - return 'architecture'; - } - if (looksLikePattern(doc)) { - return 'pattern'; - } - return null; -} - -/** - * Like {@link tryDetectDocumentType} but throws when the input matches neither - * shape, so malformed input is surfaced rather than silently diffed to an empty - * result. - */ -export function detectDocumentType(doc: Record): DiffDocumentType { - const detected = tryDetectDocumentType(doc); - if (detected) { - return detected; - } - throw new Error( - 'Could not determine the CALM document type: expected an architecture ' + - '(top-level nodes/relationships arrays) or a pattern (a JSON Schema ' + - 'describing them). Pass --type architecture|pattern to specify it explicitly.', - ); -} - -export async function runDiff( - docAPath: string, - docBPath: string, - options: DiffRunOptions = {}, -): Promise { +export async function runDiff(docAPath: string, docBPath: string, options: DiffRunOptions = {}): Promise { const logger = initLogger(!!options.verbose, 'calm-diff'); - const format = options.format ?? 'json'; - logger.info(`Comparing ${docAPath} -> ${docBPath}`); - const docA = readDocument(docAPath); - const docB = readDocument(docBPath); - - let documentType: DiffDocumentType; - if (options.documentType) { - // An explicit --type overrides auto-detection (and rescues genuinely - // ambiguous inputs), but if a document's content confidently matches the - // opposite type the override is almost certainly a mistake — fail loudly - // rather than emit a misleading empty diff. - documentType = options.documentType; - for (const [docPath, doc] of [[docAPath, docA], [docBPath, docB]] as const) { - const detected = tryDetectDocumentType(doc); - if (detected && detected !== documentType) { - throw new Error( - `--type was set to '${documentType}', but ${docPath} matches '${detected}'. ` + - 'Remove --type to auto-detect, or pass inputs of the forced type.', - ); - } - } - } else { - const typeA = detectDocumentType(docA); - const typeB = detectDocumentType(docB); - if (typeA !== typeB) { - throw new Error( - `Cannot diff mismatched document types: ${typeA} vs ${typeB}. Both inputs must be the ` + - 'same CALM document type; pass --type to override detection.', - ); - } - documentType = typeA; - } - - const diff = documentType === 'pattern' - ? diffPatterns(docA, docB) - : diffArchitectures(docA as CalmArchitectureSchema, docB as CalmArchitectureSchema); - - const invalidNodeCount = diff.invalidItems?.nodes.length ?? 0; - const invalidEdgeCount = diff.invalidItems?.relationships.length ?? 0; - if (invalidNodeCount + invalidEdgeCount > 0) { - logger.warn( - `Skipped ${invalidNodeCount} node(s) and ${invalidEdgeCount} relationship(s) ` + - 'because they were missing a unique-id. These items are reported under ' + - 'invalidItems and contribute to hasChanges so --exit-code does not pass on them silently.', - ); - } - - const undiffableNodeCount = diff.undiffableItems?.nodes.length ?? 0; - const undiffableEdgeCount = diff.undiffableItems?.relationships.length ?? 0; - if (undiffableNodeCount + undiffableEdgeCount > 0) { - logger.warn( - `Could not diff ${undiffableNodeCount} node(s) and ${undiffableEdgeCount} relationship(s) ` + - 'because they constrain no comparable content (e.g. an unconstrained unique-id). ' + - 'These items are reported under undiffableItems and contribute to hasChanges so ' + - '--exit-code does not pass on them silently.', - ); - } - - const formatted = formatDiff(diff, format, documentType); - + const result = diffDocuments(readDocument(docAPath), readDocument(docBPath), { + format: options.format, + verbose: options.verbose, + documentType: options.documentType, + labels: [docAPath, docBPath], + }); if (options.outputPath) { const dir = path.dirname(path.resolve(options.outputPath)); mkdirp.sync(dir); - fs.writeFileSync(options.outputPath, formatted); + fs.writeFileSync(options.outputPath, result.formatted); logger.info(`Wrote diff to ${options.outputPath}`); } - - return { diff, formatted, hasChanges: hasChanges(diff) }; -} - -export interface TimelineDiffRunOptions { - /** Diff only this single pair instead of all adjacent pairs. */ - fromMomentId?: string; - toMomentId?: string; - verbose?: boolean; -} - -export interface TimelineDiffRunResult { - /** Ordered diffs: one per adjacent pair, or a single entry for an explicit pair. */ - diffs: MomentDiff[]; + return result; } /** @@ -258,9 +54,7 @@ export interface TimelineDiffRunResult { */ export function createFileSystemArchitectureResolver(baseDir: string): ArchitectureResolver { return async (reference: string) => { - const resolved = path.isAbsolute(reference) - ? reference - : path.resolve(baseDir, reference); + const resolved = path.isAbsolute(reference) ? reference : path.resolve(baseDir, reference); const raw = await fs.promises.readFile(resolved, 'utf-8'); return JSON.parse(raw) as Record; }; @@ -272,32 +66,10 @@ export function createFileSystemArchitectureResolver(baseDir: string): Architect * adjacent moment pairs unless an explicit {@link TimelineDiffRunOptions.fromMomentId} * / {@link TimelineDiffRunOptions.toMomentId} pair is supplied. */ -export async function runTimelineDiff( - timelinePath: string, - options: TimelineDiffRunOptions = {}, -): Promise { +export async function runTimelineDiff(timelinePath: string, options: TimelineDiffRunOptions = {}): Promise { const logger = initLogger(!!options.verbose, 'calm-timeline-diff'); const resolvedPath = path.resolve(timelinePath); logger.info(`Diffing timeline ${resolvedPath}`); - const timeline = readDocument(resolvedPath) as TimelineInput; - const resolver = createFileSystemArchitectureResolver(path.dirname(resolvedPath)); - - if (options.fromMomentId || options.toMomentId) { - if (!options.fromMomentId || !options.toMomentId) { - throw new Error( - 'Both fromMomentId and toMomentId must be supplied to diff a specific pair.', - ); - } - const diff = await diffTimelineMoments( - timeline, - options.fromMomentId, - options.toMomentId, - resolver, - ); - return { diffs: [diff] }; - } - - const diffs = await diffTimelineAdjacent(timeline, resolver); - return { diffs }; + return diffTimeline(timeline, createFileSystemArchitectureResolver(path.dirname(resolvedPath)), options); } From c0ca5f32a948b16717e2f2dc0b637227e73d0eae Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:01:13 +0100 Subject: [PATCH 10/25] feat(shared): add a browser capability manifest for CLI commands --- shared/src/browser-capabilities.spec.ts | 25 ++++++++++++++++++++ shared/src/browser-capabilities.ts | 31 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 shared/src/browser-capabilities.spec.ts create mode 100644 shared/src/browser-capabilities.ts diff --git a/shared/src/browser-capabilities.spec.ts b/shared/src/browser-capabilities.spec.ts new file mode 100644 index 000000000..cf225d394 --- /dev/null +++ b/shared/src/browser-capabilities.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { BROWSER_COMMAND_SUPPORT, browserSupportFor } from './browser-capabilities'; + +describe('browser capability manifest', () => { + it('marks the pure engine commands as supported', () => { + for (const cmd of ['validate', 'generate', 'diff', 'timeline']) { + expect(browserSupportFor(cmd)).toEqual({ command: cmd, status: 'supported' }); + } + }); + + it('gives a reason for every unsupported command', () => { + for (const entry of BROWSER_COMMAND_SUPPORT.filter((e) => e.status === 'unsupported')) { + expect(entry.reason.length).toBeGreaterThan(10); + } + }); + + it('returns undefined for unknown commands', () => { + expect(browserSupportFor('frobnicate')).toBeUndefined(); + }); + + it('has no duplicate keys', () => { + const keys = BROWSER_COMMAND_SUPPORT.map((e) => e.command); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/shared/src/browser-capabilities.ts b/shared/src/browser-capabilities.ts new file mode 100644 index 000000000..6a470f7af --- /dev/null +++ b/shared/src/browser-capabilities.ts @@ -0,0 +1,31 @@ +/** + * Which `calm` CLI commands the browser entry point can honour. Browser consumers (e.g. the + * in-browser learning lab) use this to report honestly which commands are available and why the + * others are not. `cli/src/browser-manifest.spec.ts` asserts this list matches the commands the + * CLI actually registers, so the two cannot drift. + */ +export type BrowserCommandSupport = + | { command: string; status: 'supported' } + | { command: string; status: 'unsupported'; reason: string }; + +const FILESYSTEM_REASON = 'reads template bundles and writes its output through the local filesystem'; + +export const BROWSER_COMMAND_SUPPORT: readonly BrowserCommandSupport[] = [ + { command: 'validate', status: 'supported' }, + { command: 'generate', status: 'supported' }, + { command: 'diff', status: 'supported' }, + { command: 'timeline', status: 'supported' }, + { command: 'template', status: 'unsupported', reason: FILESYSTEM_REASON }, + { command: 'docify', status: 'unsupported', reason: `${FILESYSTEM_REASON}, and rasterises diagrams with a headless browser` }, + { command: 'init-ai', status: 'unsupported', reason: 'installs AI assistant files into the local project' }, + { command: 'init-config', status: 'unsupported', reason: 'writes the CLI configuration file on the local machine' }, + { command: 'hub pull', status: 'unsupported', reason: 'reads from a CALM Hub over HTTP, which needs CORS headers on the target Hub' }, + { command: 'hub list', status: 'unsupported', reason: 'reads from a CALM Hub over HTTP, which needs CORS headers on the target Hub' }, + { command: 'hub push', status: 'unsupported', reason: 'writes to a CALM Hub; browser consumers simulate publishing instead' }, + { command: 'hub create', status: 'unsupported', reason: 'writes to a CALM Hub; browser consumers simulate publishing instead' }, + { command: 'workspace', status: 'unsupported', reason: 'operates on a git-rooted workspace bundle on the local filesystem' }, +]; + +export function browserSupportFor(command: string): BrowserCommandSupport | undefined { + return BROWSER_COMMAND_SUPPORT.find((entry) => entry.command === command); +} From 623aabd2bd4a53b2434f1bbe6e1c0e805baa4e26 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:07:45 +0100 Subject: [PATCH 11/25] feat(shared): add the @finos/calm-shared/browser entry point and package exports map --- cli/src/browser-manifest.spec.ts | 27 ++ package-lock.json | 524 +---------------------------- shared/package.json | 12 +- shared/src/browser-surface.spec.ts | 58 ++++ shared/src/browser.ts | 74 ++++ shared/src/index.ts | 5 + 6 files changed, 184 insertions(+), 516 deletions(-) create mode 100644 cli/src/browser-manifest.spec.ts create mode 100644 shared/src/browser-surface.spec.ts create mode 100644 shared/src/browser.ts diff --git a/cli/src/browser-manifest.spec.ts b/cli/src/browser-manifest.spec.ts new file mode 100644 index 000000000..1d90cab7c --- /dev/null +++ b/cli/src/browser-manifest.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { setupCLI } from './cli'; +import { BROWSER_COMMAND_SUPPORT } from '@finos/calm-shared/browser'; + +function registeredCommandKeys(): string[] { + const program = new Command(); + setupCLI(program); + const keys: string[] = []; + for (const command of program.commands) { + if (command.name() === 'hub') { + for (const sub of command.commands) { + keys.push(`hub ${sub.name()}`); + } + } else { + keys.push(command.name()); + } + } + return keys.sort(); +} + +describe('browser capability manifest matches the CLI', () => { + it('lists every registered command exactly once', () => { + const manifest = BROWSER_COMMAND_SUPPORT.map((entry) => entry.command).sort(); + expect(manifest).toEqual(registeredCommandKeys()); + }); +}); diff --git a/package-lock.json b/package-lock.json index 1cf2adeb1..c7b37c407 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1189,7 +1189,7 @@ }, "cli": { "name": "@finos/calm-cli", - "version": "1.55.0", + "version": "1.56.0", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -2458,15 +2458,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@asyncapi/specs": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", - "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.11" - } - }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -14715,81 +14706,6 @@ "node": ">=8" } }, - "node_modules/@stoplight/spectral-cli": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.16.2.tgz", - "integrity": "sha512-1BwNCglpvCA2J+MpdXSPH56h69rARthf0QG/t2eeFbuqg12rcQEbGfAXs70mCRUAewiCzf1QqzeHF1tnabfS3A==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "^1.4.0", - "@stoplight/json": "~3.21.0", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-core": "^1.19.5", - "@stoplight/spectral-formatters": "^1.4.1", - "@stoplight/spectral-parsers": "^1.0.4", - "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-bundler": "^1.6.0", - "@stoplight/spectral-ruleset-migrator": "^1.11.0", - "@stoplight/spectral-rulesets": ">=1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "chalk": "4.1.2", - "fast-glob": "~3.2.12", - "hpagent": "~1.2.0", - "lodash": "^4.18.1", - "pony-cause": "^1.1.1", - "stacktracey": "^2.1.8", - "tslib": "^2.8.1", - "yargs": "~17.7.2" - }, - "bin": { - "spectral": "dist/index.js" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@stoplight/spectral-core": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", @@ -14897,70 +14813,6 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, - "node_modules/@stoplight/spectral-formatters": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.1.tgz", - "integrity": "sha512-mGXaiIrPglPokSnbFqbkWN3DoozIbwrZAA6OgqSIl+djeD5+e6PMELg0g6r3ot3ZzntO+6/GXaDnxEQ/p9M/EQ==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/path": "^1.3.2", - "@stoplight/spectral-core": "^1.19.4", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.15.0", - "@types/markdown-escape": "^1.1.3", - "chalk": "4.1.2", - "cliui": "7.0.4", - "lodash": "^4.18.1", - "markdown-escape": "^2.0.0", - "node-sarif-builder": "^2.0.3", - "strip-ansi": "6.0", - "text-table": "^0.2.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/node-sarif-builder": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", - "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", - "license": "MIT", - "dependencies": { - "@types/sarif": "^2.1.4", - "fs-extra": "^10.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@stoplight/spectral-functions": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", @@ -15032,251 +14884,6 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, - "node_modules/@stoplight/spectral-ruleset-bundler": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", - "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", - "license": "Apache-2.0", - "dependencies": { - "@rollup/plugin-commonjs": "~22.0.2", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-core": ">=1", - "@stoplight/spectral-formats": "^1.8.1", - "@stoplight/spectral-functions": ">=1", - "@stoplight/spectral-parsers": ">=1", - "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-migrator": "^1.9.6", - "@stoplight/spectral-rulesets": ">=1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@types/node": "*", - "pony-cause": "1.1.1", - "rollup": "~2.80.0", - "tslib": "^2.8.1", - "validate-npm-package-name": "3.0.0" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/plugin-commonjs": { - "version": "22.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", - "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "commondir": "^1.0.1", - "estree-walker": "^2.0.1", - "glob": "^7.1.6", - "is-reference": "^1.2.1", - "magic-string": "^0.25.7", - "resolve": "^1.17.0" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "rollup": "^2.68.0" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.12.1.tgz", - "integrity": "sha512-IUEbDmmTro0oF6VoAtrUySRV/b6bvYmV7wV6lB99f0Ym5lF9M2DXcgPLo7VMbKTPjCOQcaBzWRnIMXAyLjIRMA==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/json": "~3.21.0", - "@stoplight/ordered-object-literal": "~1.0.4", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-functions": "^1.9.1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@stoplight/yaml": "~4.2.3", - "@types/node": "*", - "ajv": "^8.18.0", - "ast-types": "0.14.2", - "astring": "^1.9.0", - "reserved": "0.1.2", - "tslib": "^2.8.1", - "validate-npm-package-name": "3.0.0" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", - "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/ordered-object-literal": "^1.0.1", - "@stoplight/types": "^13.0.0", - "@stoplight/yaml-ast-parser": "0.0.48", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=10.8" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", - "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", - "license": "Apache-2.0" - }, - "node_modules/@stoplight/spectral-rulesets": { - "version": "1.22.6", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.6.tgz", - "integrity": "sha512-xBwrb2zjx+7AzGS3aX7aOtddChRRw8aoQMu8ZT5AmTfEr0VAj4ydGC6Pl7lIvAIomcO7hw+P4I2oylTOOCkUVw==", - "license": "Apache-2.0", - "dependencies": { - "@asyncapi/specs": "^6.8.0", - "@scarf/scarf": "^1.4.0", - "@stoplight/better-ajv-errors": "1.0.3", - "@stoplight/json": "^3.17.0", - "@stoplight/spectral-core": "^1.23.0", - "@stoplight/spectral-formats": "^1.8.1", - "@stoplight/spectral-functions": "^1.9.1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@types/json-schema": "^7.0.7", - "ajv": "^8.18.0", - "ajv-formats": "~2.1.1", - "json-schema-traverse": "^1.0.0", - "leven": "3.1.0", - "lodash": "^4.18.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-rulesets/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-rulesets/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/@stoplight/spectral-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", @@ -18054,12 +17661,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/markdown-escape": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", - "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -18189,6 +17790,7 @@ "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/sax": { @@ -20414,15 +20016,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", - "license": "MIT", - "dependencies": { - "printable-characters": "^1.0.42" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -20474,18 +20067,6 @@ "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", - "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -21227,12 +20808,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "license": "MIT" - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -22364,12 +21939,6 @@ "node": ">=4.0.0" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -24406,12 +23975,6 @@ "node": ">=0.10" } }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "license": "MIT" - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -27741,25 +27304,6 @@ "node": ">= 0.4" } }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "license": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } - }, - "node_modules/get-source/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -28832,15 +28376,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/hpagent": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", - "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -32550,12 +32085,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "license": "ISC" }, - "node_modules/markdown-escape": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", - "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", - "license": "MIT" - }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -41072,12 +40601,6 @@ "node": ">=4" } }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "license": "Unlicense" - }, "node_modules/prism-react-renderer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", @@ -42667,14 +42190,6 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, - "node_modules/reserved": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", - "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -44746,13 +44261,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "license": "MIT" - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -44967,16 +44475,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stacktracey": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", - "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", - "license": "Unlicense", - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -46514,6 +46012,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, "license": "MIT" }, "node_modules/textextensions": { @@ -48047,15 +47546,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "license": "ISC", - "dependencies": { - "builtins": "^1.0.3" - } - }, "node_modules/value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", @@ -49494,6 +48984,7 @@ "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -49512,6 +49003,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -49521,6 +49013,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -49535,12 +49028,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -49550,6 +49045,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -49564,6 +49060,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -49718,7 +49215,6 @@ "@finos/calm-widgets": "file:../calm-widgets", "@mermaid-js/layout-elk": "^0.2.1", "@stoplight/json": "^3.21.7", - "@stoplight/spectral-cli": "^6.14.3", "@stoplight/spectral-core": "^1.19.5", "@stoplight/spectral-functions": "^1.9.4", "ajv": "^8.18.0", diff --git a/shared/package.json b/shared/package.json index 4d706765a..56c27c40f 100644 --- a/shared/package.json +++ b/shared/package.json @@ -4,6 +4,16 @@ "description": "A set of tools for interacting with the Common Architecture Language Model (CALM)", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "default": "./dist/browser.js" + } + }, "files": [ "dist" ], @@ -27,7 +37,6 @@ ], "author": "", "license": "ISC", - "module": "esnext", "type": "module", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -35,7 +44,6 @@ "@finos/calm-widgets": "file:../calm-widgets", "@mermaid-js/layout-elk": "^0.2.1", "@stoplight/json": "^3.21.7", - "@stoplight/spectral-cli": "^6.14.3", "@stoplight/spectral-core": "^1.19.5", "@stoplight/spectral-functions": "^1.9.4", "ajv": "^8.18.0", diff --git a/shared/src/browser-surface.spec.ts b/shared/src/browser-surface.spec.ts new file mode 100644 index 000000000..726dc40ac --- /dev/null +++ b/shared/src/browser-surface.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; +import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, generate, diffDocuments } from './browser'; + +// The spec itself reads the meta-schemas from disk; the code under test only ever sees objects. +const META_DIR = path.join(__dirname, '../../calm/release/1.2/meta'); +const schemas: Record = Object.fromEntries( + readdirSync(META_DIR).filter((f) => f.endsWith('.json')).map((f) => { + const doc = JSON.parse(readFileSync(path.join(META_DIR, f), 'utf-8')); + return [doc.$id, doc]; + }) +); + +const validArch = { + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + 'unique-id': 'arch', + nodes: [ + { 'unique-id': 'svc', 'node-type': 'service', name: 'Service', description: 'a service' }, + { 'unique-id': 'db', 'node-type': 'database', name: 'DB', description: 'a database' }, + ], + relationships: [ + { 'unique-id': 'svc-db', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: 'db' } } } }, + ], +}; + +async function schemaDirectory(extra: Record = {}): Promise { + const dir = new SchemaDirectory(buildBrowserDocumentLoader({ documents: { ...schemas, ...extra }, allowRemote: false })); + await dir.loadSchemas(); + return dir; +} + +describe('browser entry point', () => { + it('validates a well-formed architecture with schema + spectral rules through injected loaders', async () => { + const outcome = await validate(validArch, undefined, undefined, await schemaDirectory()); + expect(outcome.hasErrors).toBe(false); + expect(formatOutput(outcome, 'pretty')).toContain('No issues found'); + }); + + it('reports a dangling relationship reference via the spectral rules', async () => { + const broken = { ...validArch, relationships: [{ 'unique-id': 'x', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: 'ghost' } } } }] }; + const outcome = await validate(broken, undefined, undefined, await schemaDirectory()); + expect(outcome.hasErrors).toBe(true); + expect(outcome.spectralSchemaValidationOutputs.some((o) => /ghost/.test(o.message))).toBe(true); + }); + + it('refuses junit formatting with a clear error', async () => { + const outcome = await validate(validArch, undefined, undefined, await schemaDirectory()); + expect(() => formatOutput(outcome, 'junit')).toThrow(/junit.*not available/i); + }); + + it('exposes the pure generate and diff cores', async () => { + const pattern = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: 'https://x/p.json', type: 'object', properties: { nodes: { type: 'array', prefixItems: [] }, relationships: { type: 'array', prefixItems: [] } } }; + const generated = await generate(pattern, await schemaDirectory()) as { nodes: unknown[] }; + expect(generated.nodes).toEqual([]); + expect(diffDocuments(validArch, { ...validArch, nodes: validArch.nodes.slice(0, 1) }).hasChanges).toBe(true); + }); +}); diff --git a/shared/src/browser.ts b/shared/src/browser.ts new file mode 100644 index 000000000..b344eaef0 --- /dev/null +++ b/shared/src/browser.ts @@ -0,0 +1,74 @@ +/** + * Browser-safe entry point (`@finos/calm-shared/browser`). + * + * Everything exported here must be importable in a browser bundle: no `fs`, `path`, `net`, + * `winston`, `mkdirp`, `playwright-core`, no `process.exit`, no `__dirname`. The guard script + * `scripts/check-browser-entry.mjs` enforces this on every test run. Node-only code lives + * behind the root entry (`index.ts`) and is never imported from here. + */ +export { + validate, + formatOutput, + formatOutput as getFormattedOutput, + registerOutputFormatter, + type OutputFormat, + type ValidateOutputFormat, + type OutputFormatter, + type ValidationDocumentContext, + type ValidationFormattingOptions, +} from './commands/validate/validate.js'; +export { ValidationOutcome, ValidationOutput } from './commands/validate/validation.output.js'; +export { + enrichWithDocumentPositions, + parseDocumentWithPositions, + type ParsedDocumentContext, +} from './commands/validate/validation-enrichment.js'; +export { SchemaDirectory } from './schema-directory.js'; +export { + type DocumentLoader, + DocumentLoadError, + assertJsonObject, + CALM_HUB_PROTOS, +} from './document-loader/document-loader.js'; +export { InMemoryDocumentLoader } from './document-loader/in-memory-document-loader.js'; +export { CalmHubDocumentLoader } from './document-loader/calmhub-document-loader.js'; +export { DirectUrlDocumentLoader } from './document-loader/direct-url-document-loader.js'; +export { MultiStrategyDocumentLoader } from './document-loader/multi-strategy-document-loader.js'; +export { buildBrowserDocumentLoader, type BrowserDocumentLoaderOptions } from './document-loader/browser-document-loader.js'; +export { generate, type GenerateOptions } from './commands/generate/generate-core.js'; +export { extractOptions, selectChoices, CalmChoice, CalmOption } from './commands/generate/components/options.js'; +export { + diffDocuments, + diffTimeline, + formatDiff, + detectDocumentType, + tryDetectDocumentType, + hasChanges as diffHasChanges, + type DiffOutputFormat, + type DiffDocumentType, + type DiffDocumentsOptions, + type DiffRunResult, + type TimelineDiffRunOptions, + type TimelineDiffRunResult, +} from './commands/diff/diff-core.js'; +export type { ArchitectureResolver, MomentDiff } from '@finos/calm-models/diff'; +export { initLogger, registerNodeLoggerFactory } from './logger.js'; +export type { Logger, LogLevel, NodeLoggerFactory } from './logger.js'; +export { AuthPlugin } from './auth/auth-plugin.js'; +export { NoAuthPlugin } from './auth/no-auth-plugin.js'; +export { + constructDocumentId, + isConformantDocumentId, + namespaceFromDocumentId, + extractDocumentMetadata, + updateDocumentMetadata, + type DocumentMetadata, + constructControlDocumentId, + extractControlMetadata, + updateControlDocumentMetadata, + type ControlDocumentMetadata, + type ControlDocumentKind, +} from './hub/document-id-utils.js'; +export { computeSemVerBump, compareSemVer, sortSemVer } from './hub/semver.js'; +export { canonicalEqual, canonicalize } from './hub/canonical.js'; +export { BROWSER_COMMAND_SUPPORT, browserSupportFor, type BrowserCommandSupport } from './browser-capabilities.js'; diff --git a/shared/src/index.ts b/shared/src/index.ts index c2f2cf33f..5ef9dd46a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -107,3 +107,8 @@ export { type ParsedDocumentContext, __test__ as validationEnrichmentTest } from './commands/validate/validation-enrichment.js'; +export { InMemoryDocumentLoader } from './document-loader/in-memory-document-loader.js'; +export { buildBrowserDocumentLoader, type BrowserDocumentLoaderOptions } from './document-loader/browser-document-loader.js'; +export { generate, type GenerateOptions } from './commands/generate/generate-core.js'; +export { diffDocuments, diffTimeline, tryDetectDocumentType, type DiffDocumentsOptions } from './commands/diff/diff-core.js'; +export { BROWSER_COMMAND_SUPPORT, browserSupportFor, type BrowserCommandSupport } from './browser-capabilities.js'; From ac31a32f04b28b32a85549b9fbabbdde043981f3 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:17:39 +0100 Subject: [PATCH 12/25] test(shared): guard the browser entry point with an esbuild bundle and runtime probe --- package-lock.json | 4 +- shared/package.json | 4 +- shared/scripts/browser-probe.ts | 50 +++++++++++++ shared/scripts/check-browser-entry.mjs | 99 ++++++++++++++++++++++++++ shared/vitest.config.ts | 2 +- 5 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 shared/scripts/browser-probe.ts create mode 100644 shared/scripts/check-browser-entry.mjs diff --git a/package-lock.json b/package-lock.json index c7b37c407..7d0767ee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49237,7 +49237,9 @@ "ts-node": "10.9.2", "winston": "^3.17.0" }, - "devDependencies": {} + "devDependencies": { + "esbuild": "^0.28.1" + } }, "shared/node_modules/ajv-formats": { "version": "2.1.1", diff --git a/shared/package.json b/shared/package.json index 56c27c40f..94fca2c88 100644 --- a/shared/package.json +++ b/shared/package.json @@ -23,7 +23,8 @@ "copy:docify-template-bundle": "node scripts/copy-templates.mjs", "watch": "tsc -watch -p ./tsconfig.build.json", "clean": "rimraf dist tsconfig.build.tsbuildinfo", - "test": "vitest run", + "test": "node scripts/check-browser-entry.mjs && vitest run", + "check:browser-entry": "node scripts/check-browser-entry.mjs", "lint": "eslint src", "lint-fix": "eslint src --fix", "dependency-check": "dependency-check --project 'calm-shared' --scan . --out ./dependency-check-report --format ALL --suppression ../.github/node-cve-ignore-list.xml" @@ -67,6 +68,7 @@ "winston": "^3.17.0" }, "devDependencies": { + "esbuild": "^0.28.1" }, "overrides": { "path-to-regexp": "8.4.2", diff --git a/shared/scripts/browser-probe.ts b/shared/scripts/browser-probe.ts new file mode 100644 index 000000000..43919ad6b --- /dev/null +++ b/shared/scripts/browser-probe.ts @@ -0,0 +1,50 @@ +// Bundled by check-browser-entry.mjs with fs/path stubbed to throw on touch. Exercises the real +// validate() path (JSON Schema + Spectral) through the browser entry with in-memory schemas. +import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, browserSupportFor } from '../src/browser'; +import calm from '../../calm/release/1.2/meta/calm.json'; +import core from '../../calm/release/1.2/meta/core.json'; +import iface from '../../calm/release/1.2/meta/interface.json'; +import control from '../../calm/release/1.2/meta/control.json'; +import controlRequirement from '../../calm/release/1.2/meta/control-requirement.json'; +import evidence from '../../calm/release/1.2/meta/evidence.json'; +import flow from '../../calm/release/1.2/meta/flow.json'; +import units from '../../calm/release/1.2/meta/units.json'; +import decorators from '../../calm/release/1.2/meta/decorators.json'; +import timeline from '../../calm/release/1.2/meta/timeline.json'; +import calmTimeline from '../../calm/release/1.2/meta/calm-timeline.json'; + +const documents: Record = Object.fromEntries( + [calm, core, iface, control, controlRequirement, evidence, flow, units, decorators, timeline, calmTimeline] + .map((schema) => [(schema as { $id: string }).$id, schema]) +); + +const arch = (destination: string) => ({ + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + 'unique-id': 'probe', + nodes: [ + { 'unique-id': 'svc', 'node-type': 'service', name: 'Service', description: 'a service' }, + { 'unique-id': 'db', 'node-type': 'database', name: 'DB', description: 'a database' }, + ], + relationships: [ + { 'unique-id': 'svc-db', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: destination } } } }, + ], +}); + +async function directory(): Promise { + const dir = new SchemaDirectory(buildBrowserDocumentLoader({ documents, allowRemote: false })); + await dir.loadSchemas(); + return dir; +} + +const good = await validate(arch('db'), undefined, undefined, await directory()); +if (good.hasErrors) { + throw new Error('probe: valid architecture reported errors:\n' + formatOutput(good, 'pretty')); +} +const bad = await validate(arch('ghost'), undefined, undefined, await directory()); +if (!bad.hasErrors) { + throw new Error('probe: dangling relationship was not reported'); +} +if (browserSupportFor('docify')?.status !== 'unsupported') { + throw new Error('probe: manifest missing docify'); +} +console.log('browser probe ok: ' + bad.spectralSchemaValidationOutputs.length + ' spectral issue(s) on the broken document'); diff --git a/shared/scripts/check-browser-entry.mjs b/shared/scripts/check-browser-entry.mjs new file mode 100644 index 000000000..55d987909 --- /dev/null +++ b/shared/scripts/check-browser-entry.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Guards the browser entry point: bundles src/browser.ts for the browser, fails on any Node +// builtin request outside the documented allowlist, then executes a probe with those builtins +// stubbed to throw if touched. Run as part of `npm test` (see package.json). +import * as esbuild from 'esbuild'; +import { builtinModules } from 'node:module'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sharedRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(sharedRoot, '..'); +const builtins = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); + +// Every Node builtin the browser bundle is allowed to *request* (none may be *touched* at +// runtime on the validate path — the probe proves that). Each entry: builtin + a regex on the +// importer path. Anything else fails the build. Extend only with a matching probe change. +const ALLOWED = [ + { builtin: 'fs', importer: /@stoplight\/spectral-runtime\/dist\/reader\.js$/ }, + { builtin: 'fs', importer: /@stoplight\/json-ref-readers\/file\.js$/ }, + { builtin: 'path', importer: /minimatch\/minimatch\.js$/ }, + { builtin: 'buffer', importer: /@stoplight\/yaml-ast-parser\/dist\/src\/type\/binary\.js$/ }, +]; + +function stubPlugin(requests) { + return { + name: 'browser-entry-guard', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (!builtins.has(args.path)) return null; + const builtin = args.path.replace(/^node:/, ''); + requests.push({ builtin, importer: args.importer }); + return { path: builtin, namespace: 'guard-stub' }; + }); + build.onLoad({ filter: /.*/, namespace: 'guard-stub' }, (args) => ({ + loader: 'js', + contents: args.path === 'buffer' + ? 'export const Buffer = undefined; export default { Buffer };' + : `const stub = new Proxy({}, { get(_, key) { + if (key === '__esModule' || key === 'default' || key === 'then') return undefined; + throw new Error('browser entry touched Node builtin ${args.path}.' + String(key) + ' at runtime'); + } }); + export default stub;`, + })); + }, + }; +} + +async function bundle(entry, outfile, requests) { + await esbuild.build({ + entryPoints: [entry], + outfile, + bundle: true, + platform: 'browser', + format: 'esm', + mainFields: ['browser', 'module', 'main'], + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'silent', + plugins: [stubPlugin(requests)], + }); +} + +function checkRequests(requests) { + const problems = []; + for (const { builtin, importer } of requests) { + const rel = path.relative(repoRoot, importer); + if (importer.startsWith(path.join(sharedRoot, 'src'))) { + problems.push(`shared source imports Node builtin '${builtin}': ${rel}`); + continue; + } + if (!ALLOWED.some((a) => a.builtin === builtin && a.importer.test(importer))) { + problems.push(`unexpected Node builtin '${builtin}' requested by ${rel}`); + } + } + return problems; +} + +const workDir = await mkdtemp(path.join(tmpdir(), 'calm-browser-guard-')); +try { + const entryRequests = []; + await bundle(path.join(sharedRoot, 'src/browser.ts'), path.join(workDir, 'browser.js'), entryRequests); + const problems = checkRequests(entryRequests); + if (problems.length) { + console.error('Browser entry guard FAILED:\n ' + problems.join('\n ')); + process.exit(1); + } + console.log(`browser entry: ${entryRequests.length} allowlisted builtin request(s), none from shared/src`); + + const probeOut = path.join(workDir, 'probe.js'); + await bundle(path.join(here, 'browser-probe.ts'), probeOut, []); + await import(pathToFileURL(probeOut).href); +} catch (err) { + console.error('Browser entry guard FAILED: ' + (err instanceof Error ? err.message : String(err))); + process.exit(1); +} finally { + await rm(workDir, { recursive: true, force: true }); +} diff --git a/shared/vitest.config.ts b/shared/vitest.config.ts index ba641fd37..60b7e01bc 100644 --- a/shared/vitest.config.ts +++ b/shared/vitest.config.ts @@ -11,7 +11,7 @@ const v8CoverageSettings: CoverageV8Options = { lines: 75, statements: 75 }, - exclude: ['test_fixtures/**', '*.config.ts'], + exclude: ['test_fixtures/**', '*.config.ts', 'scripts/**'], include: ['**/*.ts'] }; From 6513046b8e3bf3da7f7a0f15fe6f227a61279f38 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:23:42 +0100 Subject: [PATCH 13/25] fix(shared): let the browser entry guard clean up its temp dir on failure --- shared/scripts/check-browser-entry.mjs | 41 +++++++++++++++----------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/shared/scripts/check-browser-entry.mjs b/shared/scripts/check-browser-entry.mjs index 55d987909..afce63fdb 100644 --- a/shared/scripts/check-browser-entry.mjs +++ b/shared/scripts/check-browser-entry.mjs @@ -77,23 +77,28 @@ function checkRequests(requests) { return problems; } -const workDir = await mkdtemp(path.join(tmpdir(), 'calm-browser-guard-')); -try { - const entryRequests = []; - await bundle(path.join(sharedRoot, 'src/browser.ts'), path.join(workDir, 'browser.js'), entryRequests); - const problems = checkRequests(entryRequests); - if (problems.length) { - console.error('Browser entry guard FAILED:\n ' + problems.join('\n ')); - process.exit(1); - } - console.log(`browser entry: ${entryRequests.length} allowlisted builtin request(s), none from shared/src`); +async function main() { + const workDir = await mkdtemp(path.join(tmpdir(), 'calm-browser-guard-')); + try { + const entryRequests = []; + await bundle(path.join(sharedRoot, 'src/browser.ts'), path.join(workDir, 'browser.js'), entryRequests); + const problems = checkRequests(entryRequests); + if (problems.length) { + console.error('Browser entry guard FAILED:\n ' + problems.join('\n ')); + process.exitCode = 1; + return; + } + console.log(`browser entry: ${entryRequests.length} allowlisted builtin request(s), none from shared/src`); - const probeOut = path.join(workDir, 'probe.js'); - await bundle(path.join(here, 'browser-probe.ts'), probeOut, []); - await import(pathToFileURL(probeOut).href); -} catch (err) { - console.error('Browser entry guard FAILED: ' + (err instanceof Error ? err.message : String(err))); - process.exit(1); -} finally { - await rm(workDir, { recursive: true, force: true }); + const probeOut = path.join(workDir, 'probe.js'); + await bundle(path.join(here, 'browser-probe.ts'), probeOut, []); + await import(pathToFileURL(probeOut).href); + } catch (err) { + console.error('Browser entry guard FAILED: ' + (err instanceof Error ? err.message : String(err))); + process.exitCode = 1; + } finally { + await rm(workDir, { recursive: true, force: true }); + } } + +await main(); From bf046c18bd5490b82b45abfaaad383ec4eb67118 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:33:40 +0100 Subject: [PATCH 14/25] docs(shared): document the browser entry point and the browser-safety rules --- shared/AGENTS.md | 24 ++++++++++++++++++++++-- shared/README.md | 12 ++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 00ebf149e..fbd3dc845 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -36,9 +36,26 @@ npm test npx vitest run ${TEST FILE} ``` +## Entry points: Node vs browser + +`@finos/calm-shared` exposes two entry points via the package `exports` map: + +| Entry | File | Audience | +|---|---|---| +| `@finos/calm-shared` | `src/index.ts` | CLI, calm-server, anything on Node. Registers winston logging and the JUnit formatter at load. | +| `@finos/calm-shared/browser` | `src/browser.ts` | Browser bundles (the docs learning lab, Studio/Guard). Validate (JSON Schema + Spectral), generate, diff/timeline, `SchemaDirectory`, loaders, the CLI capability manifest. | + +Rules: +- New modules are browser-safe by default. Node-only code (`fs`, `path`, `net`, `process.exit`, `__dirname`, winston, mkdirp, playwright) lives in a `*.node.ts` / `node-*.ts` module or in a wrapper that the root barrel imports — never imported from `browser.ts` or anything it reaches. +- Prefer seams over conditionals: pure core + Node wrapper (`generate-core.ts` / `generate.ts`, `diff-core.ts` / `diff.ts`), injected `DocumentLoader`s, registries (`registerNodeLoggerFactory`, `registerOutputFormatter`). +- `scripts/check-browser-entry.mjs` runs in `npm test`. It bundles `src/browser.ts` with esbuild for the browser and fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requests `fs`/`path`/`buffer` but never touches `fs`/`path` at runtime), then executes a real `validate()` probe with those builtins stubbed to throw. Do not extend the allowlist to make a red build green — fix the seam. +- Deep imports (`@finos/calm-shared/src/...`, `/dist/...`) are sealed by the `exports` map. Import from the barrel. +- Browser consumers bundling the entry must map the allowlisted builtins to nothing — webpack: `resolve.fallback: { fs: false, path: false, buffer: false }`; esbuild: the same stub plugin the guard uses. +- Not in the browser entry (follow-ups): template/docify (filesystem-bound loaders and output strategies), Hub read/write commands (CORS), diagram rasterisation. + ## Key Components -- **Document Loader** (`document-loader/`): Strategies for loading CALM documents — FileSystem, MultiStrategy, plus CalmHub, direct-URL, and mapped loaders. +- **Document Loader** (`document-loader/`): Strategies for loading CALM documents — FileSystem, MultiStrategy, plus CalmHub, direct-URL, and mapped loaders. Also `InMemoryDocumentLoader` (pass-a-map loader for tests and embedders) and `buildBrowserDocumentLoader` (the browser-entry loader factory, `document-loader/browser-document-loader.ts`). - **Template Processor** (`template/`): Handlebars-based template generation logic. - **Model Visitors** (`model-visitor/`): Visitor pattern implementations for traversing CALM models. - **Validation** (`commands/validate/`, `spectral/`): Core validation logic (Spectral integration) and output enrichment. @@ -67,7 +84,10 @@ npm run build:shared This package builds with `tsc` (not tsup/esbuild): `tsc -p ./tsconfig.build.json` followed by the `copy:docify-template-bundle` post-build step (`scripts/copy-templates.mjs`), which copies the docify -template bundles into `dist`. +template bundles into `dist`. `npm test --workspace shared` also runs `scripts/check-browser-entry.mjs` +first, an esbuild-based guard that bundles `src/browser.ts` for the browser and fails the test run if it +pulls in a Node builtin outside its allowlist or touches one at runtime — see "Entry points: Node vs +browser" above. #### Build configuration `tsconfig.build.json` is the production build config. It enables `"strict": true` and **excludes** spec diff --git a/shared/README.md b/shared/README.md index 9c76b7ac7..7eafae933 100644 --- a/shared/README.md +++ b/shared/README.md @@ -2,7 +2,19 @@ This module provides shared logic such as validation and visualization utilities, intended for use across various plugins and tools in the codebase. It simplifies code reuse and promotes a unified logic layer, making it easier to maintain and extend. +## Browser entry point +Browser bundles import from `@finos/calm-shared/browser`, not the package root — the root entry pulls in Node-only code (winston, `fs`, etc.). +The browser entry covers validate (JSON Schema + Spectral), generate, diff/timeline, `SchemaDirectory`, the document loaders, and auth plugins. +Bundlers must stub out the Node builtins the browser entry's dependency chain still requests but never touches at runtime; for webpack: + +```js +resolve: { + fallback: { fs: false, path: false, buffer: false } +} +``` + +`BROWSER_COMMAND_SUPPORT` (from `browser-capabilities.ts`) lists which `calm` CLI commands the browser entry can honour and why the rest are unsupported there, so consumers can report this to users instead of guessing. # Spectral validation rules for CALM implementations From c9d1aaa52ed029f6c64b9fd2b7a7481c5cd19f37 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:54:40 +0100 Subject: [PATCH 15/25] fix(cli): apply module resolution settings inside compilerOptions so exports subpaths type-resolve module and moduleResolution were set at the top level of cli/tsconfig.json, outside compilerOptions, so TypeScript silently ignored them and fell back to the base config's Node resolution, which is not exports-aware. This broke type resolution for package.json "exports" subpaths such as @finos/calm-shared/browser and @finos/calm-models/types. --- cli/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 1dc3e3b04..84f724692 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "../tsconfig.base.json", - "module": "Preserve", - "moduleResolution": "bundler", "compilerOptions": { + "module": "Preserve", + "moduleResolution": "bundler", "strict": true, "outDir": "dist", }, From 36a13ab9a0573ae64e8af3713391365ec0af1343 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:00:34 +0100 Subject: [PATCH 16/25] test(shared): simulate browser globals in the browser entry probe and pin SSRF ip-literal cases The guard's runtime probe ran under Node, so process/Buffer globals were still visible even though builtin module requests were stubbed; a stray process.cwd() or Buffer.from() anywhere in the browser graph would pass the guard and only throw in a real browser. Define process and Buffer as undefined for the probe bundle so it sees browser semantics. Also pin several SSRF-relevant host shapes (IPv4-mapped IPv6, zone IDs, leading zeros, trailing colons, embedded IPv4 tails) in ipLiteralVersion's test table, each confirmed against net.isIP. Also tighten the guard's shared/src importer check to require a path separator after the prefix, and document why the probe bundle's builtin requests are intentionally not re-checked. --- shared/scripts/check-browser-entry.mjs | 10 ++++++++-- shared/src/util/ip-literal.spec.ts | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/shared/scripts/check-browser-entry.mjs b/shared/scripts/check-browser-entry.mjs index afce63fdb..f4de219d6 100644 --- a/shared/scripts/check-browser-entry.mjs +++ b/shared/scripts/check-browser-entry.mjs @@ -56,7 +56,11 @@ async function bundle(entry, outfile, requests) { platform: 'browser', format: 'esm', mainFields: ['browser', 'module', 'main'], - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + 'process.env.NODE_ENV': '"production"', + process: 'undefined', + Buffer: 'undefined', + }, logLevel: 'silent', plugins: [stubPlugin(requests)], }); @@ -66,7 +70,7 @@ function checkRequests(requests) { const problems = []; for (const { builtin, importer } of requests) { const rel = path.relative(repoRoot, importer); - if (importer.startsWith(path.join(sharedRoot, 'src'))) { + if (importer.startsWith(path.join(sharedRoot, 'src') + path.sep)) { problems.push(`shared source imports Node builtin '${builtin}': ${rel}`); continue; } @@ -91,6 +95,8 @@ async function main() { console.log(`browser entry: ${entryRequests.length} allowlisted builtin request(s), none from shared/src`); const probeOut = path.join(workDir, 'probe.js'); + // The probe's module graph is the entry's graph plus JSON schema fixtures, already + // checked above, so its builtin requests are intentionally not re-checked here. await bundle(path.join(here, 'browser-probe.ts'), probeOut, []); await import(pathToFileURL(probeOut).href); } catch (err) { diff --git a/shared/src/util/ip-literal.spec.ts b/shared/src/util/ip-literal.spec.ts index bb877f077..36af6b4c1 100644 --- a/shared/src/util/ip-literal.spec.ts +++ b/shared/src/util/ip-literal.spec.ts @@ -7,6 +7,8 @@ describe('ipLiteralVersion', () => { ['::1', 6], ['fe80::1', 6], ['fc00::', 6], ['2001:db8::ff00:42:8329', 6], ['::ffff:192.168.0.1', 6], ['localhost', 0], ['calm.finos.org', 0], ['256.1.1.1', 0], ['1.2.3', 0], ['1.2.3.4.5', 0], ['', 0], ['::g', 0], ['1234:5678', 0], ['1:2:3:4:5:6:7:8:9', 0], ['a::b::c', 0], ['::', 6], + ['::ffff:127.0.0.1', 6], ['1.2.3.4:8080', 0], ['fe80::1%eth0', 0], ['01.2.3.4', 0], + ['::1:', 0], ['1:2:3:4:5:6:1.2.3.4', 6], ['::ffff:1.2.3.4.5', 0], ])('classifies %s as %s', (host, expected) => { expect(ipLiteralVersion(host)).toBe(expected); }); From 3c3a10205f4d21b6516a5105a888fc49247a4063 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:00:39 +0100 Subject: [PATCH 17/25] refactor(shared): isolate hub resource types and tidy generate core imports and specs Move ResourceType, RESOURCE_TYPES and isValidResourceType out of calm-hub-client.ts (which imports axios) into a new resource-types.ts, and have document-id-utils.ts import from there instead. calm-hub-client.ts re-exports the moved names so its public surface and the root barrel are unchanged. This keeps the axios-based hub client out of the browser bundle's graph, since document-id-utils.ts is imported by browser.ts. Add the missing .js suffixes to generate-core.ts's local imports for consistency with its other imports. Move the unmocked 'generate core' describe block out of generate.spec.ts into a new generate-core.spec.ts that imports generate-core directly and never mocks anything, so it needs no vi.doUnmock/resetModules dance that only worked because it ran last in the file. --- .../commands/generate/generate-core.spec.ts | 27 ++++++++++++++ shared/src/commands/generate/generate-core.ts | 4 +-- shared/src/commands/generate/generate.spec.ts | 35 ------------------- shared/src/hub/calm-hub-client.ts | 10 ++---- shared/src/hub/document-id-utils.ts | 2 +- shared/src/hub/resource-types.ts | 6 ++++ 6 files changed, 39 insertions(+), 45 deletions(-) create mode 100644 shared/src/commands/generate/generate-core.spec.ts create mode 100644 shared/src/hub/resource-types.ts diff --git a/shared/src/commands/generate/generate-core.spec.ts b/shared/src/commands/generate/generate-core.spec.ts new file mode 100644 index 000000000..a37da61ac --- /dev/null +++ b/shared/src/commands/generate/generate-core.spec.ts @@ -0,0 +1,27 @@ +import { generate } from './generate-core'; +import { SchemaDirectory } from '../../schema-directory'; + +describe('generate core', () => { + it('returns the instantiated architecture object without touching the filesystem', async () => { + const pattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://x/pattern.json', + type: 'object', + properties: { + nodes: { type: 'array', prefixItems: [ + { type: 'object', properties: { 'unique-id': { const: 'a' }, 'node-type': { const: 'service' }, name: { const: 'A' }, description: { const: 'd' } } } + ] }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + const schemaDirectory = { loadSchemas: vi.fn(), getSchema: vi.fn(), getDefinition: vi.fn(), loadCurrentPatternAsSchema: vi.fn() } as unknown as SchemaDirectory; + const result = await generate(pattern, schemaDirectory) as { nodes: { 'unique-id': string }[] }; + expect(schemaDirectory.loadSchemas).toHaveBeenCalled(); + expect(result.nodes[0]['unique-id']).toBe('a'); + }); + + it('propagates errors instead of swallowing them', async () => { + const schemaDirectory = { loadSchemas: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as SchemaDirectory; + await expect(generate({}, schemaDirectory)).rejects.toThrow('boom'); + }); +}); diff --git a/shared/src/commands/generate/generate-core.ts b/shared/src/commands/generate/generate-core.ts index 94ca661f6..ef7e7908c 100644 --- a/shared/src/commands/generate/generate-core.ts +++ b/shared/src/commands/generate/generate-core.ts @@ -1,6 +1,6 @@ import { CalmChoice, selectChoices } from './components/options.js'; -import { instantiate } from './components/instantiate'; -import { flattenAllOf } from './components/flatten-allof'; +import { instantiate } from './components/instantiate.js'; +import { flattenAllOf } from './components/flatten-allof.js'; import { SchemaDirectory } from '../../schema-directory.js'; export interface GenerateOptions { diff --git a/shared/src/commands/generate/generate.spec.ts b/shared/src/commands/generate/generate.spec.ts index a2db3f338..65a265bac 100644 --- a/shared/src/commands/generate/generate.spec.ts +++ b/shared/src/commands/generate/generate.spec.ts @@ -87,38 +87,3 @@ describe('runGenerate', () => { }); }); - -describe('generate core', () => { - // The module-level vi.mock calls above replace instantiate/flattenAllOf for the whole file. - // These tests need the real implementations, so unmock + reset modules before each dynamic import. - beforeEach(() => { - vi.doUnmock('./components/instantiate'); - vi.doUnmock('./components/flatten-allof'); - vi.resetModules(); - }); - - it('returns the instantiated architecture object without touching the filesystem', async () => { - const { generate } = await import('./generate-core'); - const pattern = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://x/pattern.json', - type: 'object', - properties: { - nodes: { type: 'array', prefixItems: [ - { type: 'object', properties: { 'unique-id': { const: 'a' }, 'node-type': { const: 'service' }, name: { const: 'A' }, description: { const: 'd' } } } - ] }, - relationships: { type: 'array', prefixItems: [] }, - }, - }; - const schemaDirectory = { loadSchemas: vi.fn(), getSchema: vi.fn(), getDefinition: vi.fn(), loadCurrentPatternAsSchema: vi.fn() } as unknown as SchemaDirectory; - const result = await generate(pattern, schemaDirectory) as { nodes: { 'unique-id': string }[] }; - expect(schemaDirectory.loadSchemas).toHaveBeenCalled(); - expect(result.nodes[0]['unique-id']).toBe('a'); - }); - - it('propagates errors instead of swallowing them', async () => { - const { generate } = await import('./generate-core'); - const schemaDirectory = { loadSchemas: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as SchemaDirectory; - await expect(generate({}, schemaDirectory)).rejects.toThrow('boom'); - }); -}); diff --git a/shared/src/hub/calm-hub-client.ts b/shared/src/hub/calm-hub-client.ts index 56490eb3a..acb48aa34 100644 --- a/shared/src/hub/calm-hub-client.ts +++ b/shared/src/hub/calm-hub-client.ts @@ -2,6 +2,9 @@ import axios, { Axios } from 'axios'; import { AuthPlugin } from '../auth/auth-plugin'; import { initLogger, Logger } from '../logger'; import { DocumentMetadata, extractDocumentMetadata, validateDocumentId } from './document-id-utils'; +import { ResourceType } from './resource-types.js'; + +export { ResourceType, RESOURCE_TYPES, isValidResourceType } from './resource-types.js'; export interface CalmHubOptions { calmHubUrl?: string; @@ -39,13 +42,6 @@ export interface HubControlSummary { export type ResourceChangeType = 'MAJOR' | 'MINOR' | 'PATCH'; -export type ResourceType = 'patterns' | 'architectures' | 'standards' | 'interfaces'; -export const RESOURCE_TYPES = ['patterns', 'architectures', 'standards', 'interfaces']; - -export function isValidResourceType(input: string): input is ResourceType { - return RESOURCE_TYPES.includes(input); -} - export class HubClientError extends Error { /** * Creates a normalized Hub client error. diff --git a/shared/src/hub/document-id-utils.ts b/shared/src/hub/document-id-utils.ts index 9db1503b3..66fa54ed5 100644 --- a/shared/src/hub/document-id-utils.ts +++ b/shared/src/hub/document-id-utils.ts @@ -1,4 +1,4 @@ -import { isValidResourceType, ResourceType } from './calm-hub-client'; +import { isValidResourceType, ResourceType } from './resource-types.js'; // Namespace documents: namespace-scoped // diff --git a/shared/src/hub/resource-types.ts b/shared/src/hub/resource-types.ts new file mode 100644 index 000000000..a63f6c04a --- /dev/null +++ b/shared/src/hub/resource-types.ts @@ -0,0 +1,6 @@ +export type ResourceType = 'patterns' | 'architectures' | 'standards' | 'interfaces'; +export const RESOURCE_TYPES = ['patterns', 'architectures', 'standards', 'interfaces']; + +export function isValidResourceType(input: string): input is ResourceType { + return RESOURCE_TYPES.includes(input); +} From be19f96b65dcee74869084125e988adca3d79742 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:00:43 +0100 Subject: [PATCH 18/25] docs(shared): note load-bearing registrations and the browser mainField assumption Comment above the registerNodeLoggerFactory/registerOutputFormatter calls in index.ts explaining they are load-bearing side effects, so "sideEffects": false must never be added to shared/package.json. Note in InMemoryDocumentLoader's doc comment that it returns documents by reference rather than cloning them, unlike FileSystemDocumentLoader, which re-parses from disk on every load. Note in the README that the browser entry guard's allowlist assumes bundlers resolve with the browser main field first, and that a node/SSR bundle target will see more builtins than the allowlist covers. --- shared/README.md | 2 ++ shared/src/document-loader/in-memory-document-loader.ts | 4 ++++ shared/src/index.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/shared/README.md b/shared/README.md index 7eafae933..c859d4081 100644 --- a/shared/README.md +++ b/shared/README.md @@ -14,6 +14,8 @@ resolve: { } ``` +The browser entry guard's allowlist assumes bundlers resolve dependencies with the `browser` main field first (`mainFields: ['browser', 'module', 'main']`); a node/SSR-target bundle resolves the Node builds of the same dependencies instead and will see more builtin requests than the allowlist covers. + `BROWSER_COMMAND_SUPPORT` (from `browser-capabilities.ts`) lists which `calm` CLI commands the browser entry can honour and why the rest are unsupported there, so consumers can report this to users instead of guessing. # Spectral validation rules for CALM implementations diff --git a/shared/src/document-loader/in-memory-document-loader.ts b/shared/src/document-loader/in-memory-document-loader.ts index ca21c2a10..c7bd3fdcb 100644 --- a/shared/src/document-loader/in-memory-document-loader.ts +++ b/shared/src/document-loader/in-memory-document-loader.ts @@ -10,6 +10,10 @@ import type { CalmDocumentType } from '@finos/calm-models/types'; * * Documents whose `$id` is a string are registered as schemas on initialise, so schema lookups * behave exactly as they do with {@link FileSystemDocumentLoader} over a schema directory. + * + * Documents are returned by reference, not cloned, so a consumer reusing one `documents` map + * across runs shares object identity between runs — unlike {@link FileSystemDocumentLoader}, + * which re-parses from disk on every load. */ export class InMemoryDocumentLoader implements DocumentLoader { private readonly logger: Logger; diff --git a/shared/src/index.ts b/shared/src/index.ts index 5ef9dd46a..100d88f83 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,6 +3,9 @@ import { createWinstonLogger } from './logger.node.js'; import { registerOutputFormatter } from './commands/validate/format-output.js'; import { junitFormatter } from './commands/validate/output-formats/junit-output.js'; +// These top-level calls are load-bearing side effects (they register the Node logger factory and +// the junit output formatter for consumers of this entry point) — never add "sideEffects": false +// to shared/package.json, or bundlers will tree-shake them away. registerNodeLoggerFactory(createWinstonLogger); registerOutputFormatter('junit', junitFormatter); From 01225bde495e86a99c10700b9d6fde7af8bc5043 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:03:30 +0100 Subject: [PATCH 19/25] fix(shared): accept IPv6 zone identifiers in the browser-safe ip literal check ipLiteralVersion() rejected IPv6 zone identifiers (e.g. fe80::1%eth0), diverging from net.isIP's contract, which classifies them as version 6. Split off a %zone suffix before classification: the zone must be non-empty and match Node's accepted zone charset, and the part before % must classify as IPv6 on its own (an IPv4 address with a zone id, e.g. 1.2.3.4%eth0, is never an IP literal, matching net.isIP). Pinned test rows now match net.isIP's actual output for all zone-id shapes checked (fe80::1%eth0 -> 6, fe80::1% -> 0, 1.2.3.4%eth0 -> 0, ::1%25 -> 6, fe80::1%eth0%x -> 0) instead of the implementation's previous (incorrect) behaviour. --- shared/src/util/ip-literal.spec.ts | 3 +- shared/src/util/ip-literal.ts | 48 ++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/shared/src/util/ip-literal.spec.ts b/shared/src/util/ip-literal.spec.ts index 36af6b4c1..76966a460 100644 --- a/shared/src/util/ip-literal.spec.ts +++ b/shared/src/util/ip-literal.spec.ts @@ -7,8 +7,9 @@ describe('ipLiteralVersion', () => { ['::1', 6], ['fe80::1', 6], ['fc00::', 6], ['2001:db8::ff00:42:8329', 6], ['::ffff:192.168.0.1', 6], ['localhost', 0], ['calm.finos.org', 0], ['256.1.1.1', 0], ['1.2.3', 0], ['1.2.3.4.5', 0], ['', 0], ['::g', 0], ['1234:5678', 0], ['1:2:3:4:5:6:7:8:9', 0], ['a::b::c', 0], ['::', 6], - ['::ffff:127.0.0.1', 6], ['1.2.3.4:8080', 0], ['fe80::1%eth0', 0], ['01.2.3.4', 0], + ['::ffff:127.0.0.1', 6], ['1.2.3.4:8080', 0], ['fe80::1%eth0', 6], ['01.2.3.4', 0], ['::1:', 0], ['1:2:3:4:5:6:1.2.3.4', 6], ['::ffff:1.2.3.4.5', 0], + ['fe80::1%', 0], ['1.2.3.4%eth0', 0], ['::1%25', 6], ['fe80::1%eth0%x', 0], ])('classifies %s as %s', (host, expected) => { expect(ipLiteralVersion(host)).toBe(expected); }); diff --git a/shared/src/util/ip-literal.ts b/shared/src/util/ip-literal.ts index dd9e35cca..013ded79f 100644 --- a/shared/src/util/ip-literal.ts +++ b/shared/src/util/ip-literal.ts @@ -1,18 +1,16 @@ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; const IPV4_TAIL = /(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; const HEXTET = /^[0-9a-f]{1,4}$/i; +// Node's net.isIP accepts a broad zone-id charset (interface names, numeric zone ids, etc.); +// confirmed against net.isIP('fe80::1%eth0'), ('::1%25'), ('fe80::1%'), ('fe80::1%eth0%x'). +const ZONE_ID = /^[0-9a-zA-Z.:_-]+$/; /** - * Browser-safe replacement for Node's `net.isIP`: returns 4 for an IPv4 literal, 6 for an IPv6 - * literal, otherwise 0. Handles `::` compression and IPv4-mapped tails (`::ffff:1.2.3.4`). + * Is `candidate` (with any `%zone` suffix already stripped) an IPv6 literal? Never true for an + * IPv4-only string on its own — the IPv4-mapped-tail branch below only recognises the tail as + * part of a `::`-style IPv6 address, not a bare dotted-quad. */ -export function ipLiteralVersion(host: string): 0 | 4 | 6 { - if (IPV4.test(host)) { - return 4; - } - if (!host.includes(':')) { - return 0; - } +function isIPv6Literal(host: string): boolean { let candidate = host; const mapped = candidate.match(IPV4_TAIL); if (mapped && candidate.lastIndexOf(':') < (mapped.index ?? 0)) { @@ -21,17 +19,41 @@ export function ipLiteralVersion(host: string): 0 | 4 | 6 { } const parts = candidate.split('::'); if (parts.length > 2) { - return 0; + return false; } const groups = (segment: string) => (segment === '' ? [] : segment.split(':')); const head = groups(parts[0]); const tail = parts.length === 2 ? groups(parts[1]) : []; if (![...head, ...tail].every((g) => HEXTET.test(g))) { - return 0; + return false; } const count = head.length + tail.length; if (parts.length === 2) { - return count < 8 ? 6 : 0; + return count < 8; + } + return count === 8; +} + +/** + * Browser-safe replacement for Node's `net.isIP`: returns 4 for an IPv4 literal, 6 for an IPv6 + * literal, otherwise 0. Handles `::` compression, IPv4-mapped tails (`::ffff:1.2.3.4`), and IPv6 + * zone identifiers (`fe80::1%eth0`) — a zone id is only accepted when the part before `%` + * classifies as IPv6 (an IPv4 address with a zone id, e.g. `1.2.3.4%eth0`, is not an IP literal). + */ +export function ipLiteralVersion(host: string): 0 | 4 | 6 { + const zoneIndex = host.indexOf('%'); + if (zoneIndex !== -1) { + const zone = host.slice(zoneIndex + 1); + if (!ZONE_ID.test(zone)) { + return 0; + } + return isIPv6Literal(host.slice(0, zoneIndex)) ? 6 : 0; + } + if (IPV4.test(host)) { + return 4; + } + if (!host.includes(':')) { + return 0; } - return count === 8 ? 6 : 0; + return isIPv6Literal(host) ? 6 : 0; } From 7dc2ebbe8a7285cb395d175d8112e4e2431be82c Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:50:04 +0100 Subject: [PATCH 20/25] refactor(shared): move validate into validate-core so process.exit stays out of the browser graph Mirrors the generate-core/generate and diff-core/diff seam: validate.ts kept exitBasedOffOfValidationOutcome (three process.exit calls) inside the module graph reachable from browser.ts. Moving validate() and its helpers into validate-core.ts lets browser.ts import the pure core directly, dropping exitBasedOffOfValidationOutcome out of the browser bundle entirely. --- shared/AGENTS.md | 4 +- shared/src/browser.ts | 4 +- shared/src/commands/validate/validate-core.ts | 94 ++++++++++++++++++ shared/src/commands/validate/validate.ts | 95 +------------------ 4 files changed, 100 insertions(+), 97 deletions(-) create mode 100644 shared/src/commands/validate/validate-core.ts diff --git a/shared/AGENTS.md b/shared/AGENTS.md index fbd3dc845..4d30f8852 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -47,8 +47,8 @@ npx vitest run ${TEST FILE} Rules: - New modules are browser-safe by default. Node-only code (`fs`, `path`, `net`, `process.exit`, `__dirname`, winston, mkdirp, playwright) lives in a `*.node.ts` / `node-*.ts` module or in a wrapper that the root barrel imports — never imported from `browser.ts` or anything it reaches. -- Prefer seams over conditionals: pure core + Node wrapper (`generate-core.ts` / `generate.ts`, `diff-core.ts` / `diff.ts`), injected `DocumentLoader`s, registries (`registerNodeLoggerFactory`, `registerOutputFormatter`). -- `scripts/check-browser-entry.mjs` runs in `npm test`. It bundles `src/browser.ts` with esbuild for the browser and fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requests `fs`/`path`/`buffer` but never touches `fs`/`path` at runtime), then executes a real `validate()` probe with those builtins stubbed to throw. Do not extend the allowlist to make a red build green — fix the seam. +- Prefer seams over conditionals: pure core + Node wrapper (`generate-core.ts` / `generate.ts`, `diff-core.ts` / `diff.ts`, `validate-core.ts` / `validate.ts`), injected `DocumentLoader`s, registries (`registerNodeLoggerFactory`, `registerOutputFormatter`). +- `scripts/check-browser-entry.mjs` runs in `npm test`. It bundles `src/browser.ts` with esbuild for the browser and fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requests `fs`/`path`/`buffer` but never touches `fs`/`path` at runtime), then executes a real probe (`validate()`, `generate()`, `diffDocuments()`) with those builtins stubbed to throw. Do not extend the allowlist to make a red build green — fix the seam. - Deep imports (`@finos/calm-shared/src/...`, `/dist/...`) are sealed by the `exports` map. Import from the barrel. - Browser consumers bundling the entry must map the allowlisted builtins to nothing — webpack: `resolve.fallback: { fs: false, path: false, buffer: false }`; esbuild: the same stub plugin the guard uses. - Not in the browser entry (follow-ups): template/docify (filesystem-bound loaders and output strategies), Hub read/write commands (CORS), diagram rasterisation. diff --git a/shared/src/browser.ts b/shared/src/browser.ts index b344eaef0..d9b0eebf3 100644 --- a/shared/src/browser.ts +++ b/shared/src/browser.ts @@ -6,8 +6,8 @@ * `scripts/check-browser-entry.mjs` enforces this on every test run. Node-only code lives * behind the root entry (`index.ts`) and is never imported from here. */ +export { validate } from './commands/validate/validate-core.js'; export { - validate, formatOutput, formatOutput as getFormattedOutput, registerOutputFormatter, @@ -16,7 +16,7 @@ export { type OutputFormatter, type ValidationDocumentContext, type ValidationFormattingOptions, -} from './commands/validate/validate.js'; +} from './commands/validate/format-output.js'; export { ValidationOutcome, ValidationOutput } from './commands/validate/validation.output.js'; export { enrichWithDocumentPositions, diff --git a/shared/src/commands/validate/validate-core.ts b/shared/src/commands/validate/validate-core.ts new file mode 100644 index 000000000..615b54ad2 --- /dev/null +++ b/shared/src/commands/validate/validate-core.ts @@ -0,0 +1,94 @@ +import { initLogger, Logger } from '../../logger.js'; +import { ValidationOutcome } from './validation.output.js'; +import { SchemaDirectory } from '../../schema-directory.js'; +import { ValidationContext, ValidationMode } from './validation-rule.js'; +import { createDefaultValidationEngine, ValidationEngine } from './validation-engine.js'; +import { CachingTrackingResolver } from '../../resolver/caching-tracking-resolver.js'; +import { SchemaDirectoryReferenceResolver } from '../../resolver/schema-directory-reference-resolver.js'; + +let logger: Logger; // defined later at startup + +/** + * Asserts that a schema directory was provided. Validating a pattern, a timeline, + * or an architecture against a pattern all require schema resolution, so a missing + * directory is a caller error rather than a recoverable condition. + */ +function assertSchemaDirectory(schemaDirectory: SchemaDirectory | undefined): asserts schemaDirectory is SchemaDirectory { + if (!schemaDirectory) { + throw new Error('A schema directory is required for schema validation'); + } +} + +/** + * Validation - with simple input parameters and output validation outcomes. + * + * The input combination is resolved to a {@link ValidationMode} and a {@link ValidationContext}, + * which the {@link ValidationEngine} runs through the registered rules (Spectral linting, + * JSON-Schema, controls and recursive node-details). + * + * @param architecture The architecture as a JS object, or undefined if not provided + * @param patternOrSchema The pattern (or schema) as a JS object, or undefined if not provided + * @param timeline The timeline as a JS object, or undefined if not provided + * @param schemaDirectory SchemaDirectory instance for schema resolution + * @param debug Whether to log at debug level + * @returns Validation report + */ +export async function validate( + architecture: object | undefined, + patternOrSchema: object | undefined, + timeline: object | undefined, + schemaDirectory?: SchemaDirectory, + debug: boolean = false +): Promise { + logger = initLogger(debug, 'calm-validate'); + + try { + const engine = createDefaultValidationEngine(); + const context = buildValidationContext(architecture, patternOrSchema, timeline, schemaDirectory, debug, engine); + return await engine.validate(context); + } catch (error) { + logger.error('An error occurred:' + error); + throw error; + } +} + +/** + * Resolve the input combination to a mode + context, preserving the historical caller-error + * throws for invalid combinations. + */ +function buildValidationContext( + architecture: object | undefined, + patternOrSchema: object | undefined, + timeline: object | undefined, + schemaDirectory: SchemaDirectory | undefined, + debug: boolean, + engine: ValidationEngine +): ValidationContext { + const references = new CachingTrackingResolver(new SchemaDirectoryReferenceResolver(schemaDirectory)); + const base = { references, debug, engine }; + + if (timeline) { + if (architecture) { + throw new Error('You cannot provide an architecture when validating a timeline'); + } + if (!patternOrSchema) { + throw new Error('You must provide a schema to validate the timeline against, or the timeline must reference it internally'); + } + // It is acceptable, in fact desired, for `patternOrSchema` to be set, and be the CALM timeline schema. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'timeline' as ValidationMode, timeline, pattern: patternOrSchema, schemaDirectory }; + } else if (architecture && patternOrSchema) { + // Note that patternOrSchema may be a CALM pattern, or might be the CALM core schema. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'architecture-with-pattern' as ValidationMode, architecture, pattern: patternOrSchema, schemaDirectory }; + } else if (patternOrSchema) { + // `patternOrSchema` should really be a CALM pattern in this case. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'pattern-only' as ValidationMode, pattern: patternOrSchema, schemaDirectory }; + } else if (architecture) { + return { ...base, mode: 'architecture-only' as ValidationMode, architecture, schemaDirectory }; + } + + logger.debug('You must provide an architecture, a pattern, or a timeline'); + throw new Error('You must provide an architecture, a pattern, or a timeline'); +} diff --git a/shared/src/commands/validate/validate.ts b/shared/src/commands/validate/validate.ts index 285ee4839..e6e837983 100644 --- a/shared/src/commands/validate/validate.ts +++ b/shared/src/commands/validate/validate.ts @@ -1,10 +1,6 @@ -import { initLogger, Logger } from '../../logger.js'; import { ValidationOutcome } from './validation.output.js'; -import { SchemaDirectory } from '../../schema-directory.js'; -import { ValidationContext, ValidationMode } from './validation-rule.js'; -import { createDefaultValidationEngine, ValidationEngine } from './validation-engine.js'; -import { CachingTrackingResolver } from '../../resolver/caching-tracking-resolver.js'; -import { SchemaDirectoryReferenceResolver } from '../../resolver/schema-directory-reference-resolver.js'; + +export { validate } from './validate-core.js'; // Re-export the shared helpers from their new home so existing importers/tests keep working. export { @@ -26,8 +22,6 @@ export { type OutputFormatter, } from './format-output.js'; -let logger: Logger; // defined later at startup - /** * TODO - move this out of shared and into the CLI - this is process-management code. * Given a validation outcome - exit from the process gracefully with an exit code we conrol. @@ -43,88 +37,3 @@ export function exitBasedOffOfValidationOutcome(validationOutcome: ValidationOut } process.exit(0); } - -/** - * Asserts that a schema directory was provided. Validating a pattern, a timeline, - * or an architecture against a pattern all require schema resolution, so a missing - * directory is a caller error rather than a recoverable condition. - */ -function assertSchemaDirectory(schemaDirectory: SchemaDirectory | undefined): asserts schemaDirectory is SchemaDirectory { - if (!schemaDirectory) { - throw new Error('A schema directory is required for schema validation'); - } -} - -/** - * Validation - with simple input parameters and output validation outcomes. - * - * The input combination is resolved to a {@link ValidationMode} and a {@link ValidationContext}, - * which the {@link ValidationEngine} runs through the registered rules (Spectral linting, - * JSON-Schema, controls and recursive node-details). - * - * @param architecture The architecture as a JS object, or undefined if not provided - * @param patternOrSchema The pattern (or schema) as a JS object, or undefined if not provided - * @param timeline The timeline as a JS object, or undefined if not provided - * @param schemaDirectory SchemaDirectory instance for schema resolution - * @param debug Whether to log at debug level - * @returns Validation report - */ -export async function validate( - architecture: object | undefined, - patternOrSchema: object | undefined, - timeline: object | undefined, - schemaDirectory?: SchemaDirectory, - debug: boolean = false -): Promise { - logger = initLogger(debug, 'calm-validate'); - - try { - const engine = createDefaultValidationEngine(); - const context = buildValidationContext(architecture, patternOrSchema, timeline, schemaDirectory, debug, engine); - return await engine.validate(context); - } catch (error) { - logger.error('An error occurred:' + error); - throw error; - } -} - -/** - * Resolve the input combination to a mode + context, preserving the historical caller-error - * throws for invalid combinations. - */ -function buildValidationContext( - architecture: object | undefined, - patternOrSchema: object | undefined, - timeline: object | undefined, - schemaDirectory: SchemaDirectory | undefined, - debug: boolean, - engine: ValidationEngine -): ValidationContext { - const references = new CachingTrackingResolver(new SchemaDirectoryReferenceResolver(schemaDirectory)); - const base = { references, debug, engine }; - - if (timeline) { - if (architecture) { - throw new Error('You cannot provide an architecture when validating a timeline'); - } - if (!patternOrSchema) { - throw new Error('You must provide a schema to validate the timeline against, or the timeline must reference it internally'); - } - // It is acceptable, in fact desired, for `patternOrSchema` to be set, and be the CALM timeline schema. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'timeline' as ValidationMode, timeline, pattern: patternOrSchema, schemaDirectory }; - } else if (architecture && patternOrSchema) { - // Note that patternOrSchema may be a CALM pattern, or might be the CALM core schema. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'architecture-with-pattern' as ValidationMode, architecture, pattern: patternOrSchema, schemaDirectory }; - } else if (patternOrSchema) { - // `patternOrSchema` should really be a CALM pattern in this case. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'pattern-only' as ValidationMode, pattern: patternOrSchema, schemaDirectory }; - } else if (architecture) { - return { ...base, mode: 'architecture-only' as ValidationMode, architecture, schemaDirectory }; - } - - logger.debug('You must provide an architecture, a pattern, or a timeline'); - throw new Error('You must provide an architecture, a pattern, or a timeline'); -} From f9a476b45c2ad0a387ccbc7df31fde0f2c5eb3dc Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:50:09 +0100 Subject: [PATCH 21/25] test(shared): probe generate and diff in the browser entry guard and harden the allowlist check - browser-probe.ts now exercises generate() and diffDocuments() through the browser entry, not just validate(), so process.exit-free core logic outside validate is also guarded. - check-browser-entry.mjs normalises importer path separators before matching against ALLOWED (so the guard behaves the same on Windows) and reports any ALLOWED entry that matched nothing, so the allowlist can't silently rot. - browser-capabilities.spec.ts asserts the unsupported-command list is non-empty before looping over it, and its doc comment states the drift-test's exact granularity. --- shared/scripts/browser-probe.ts | 26 +++++++++++++++++++--- shared/scripts/check-browser-entry.mjs | 29 ++++++++++++++++++++----- shared/src/browser-capabilities.spec.ts | 4 +++- shared/src/browser-capabilities.ts | 4 +++- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/shared/scripts/browser-probe.ts b/shared/scripts/browser-probe.ts index 43919ad6b..5fc7fefd7 100644 --- a/shared/scripts/browser-probe.ts +++ b/shared/scripts/browser-probe.ts @@ -1,6 +1,6 @@ // Bundled by check-browser-entry.mjs with fs/path stubbed to throw on touch. Exercises the real -// validate() path (JSON Schema + Spectral) through the browser entry with in-memory schemas. -import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, browserSupportFor } from '../src/browser'; +// validate(), generate() and diffDocuments() paths through the browser entry with in-memory schemas. +import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, browserSupportFor, generate, diffDocuments } from '../src/browser'; import calm from '../../calm/release/1.2/meta/calm.json'; import core from '../../calm/release/1.2/meta/core.json'; import iface from '../../calm/release/1.2/meta/interface.json'; @@ -47,4 +47,24 @@ if (!bad.hasErrors) { if (browserSupportFor('docify')?.status !== 'unsupported') { throw new Error('probe: manifest missing docify'); } -console.log('browser probe ok: ' + bad.spectralSchemaValidationOutputs.length + ' spectral issue(s) on the broken document'); + +const minimalPattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://x/p.json', + type: 'object', + properties: { + nodes: { type: 'array', prefixItems: [] }, + relationships: { type: 'array', prefixItems: [] }, + }, +}; +const generated = await generate(minimalPattern, await directory()) as { nodes: unknown[] }; +if (!Array.isArray(generated.nodes) || generated.nodes.length !== 0) { + throw new Error('probe: generate did not produce the expected empty nodes array'); +} + +const diffResult = diffDocuments(arch('db'), { ...arch('db'), nodes: arch('db').nodes.slice(0, 1) }); +if (diffResult.hasChanges !== true) { + throw new Error('probe: diffDocuments did not report a change for the removed node'); +} + +console.log('browser probe ok: ' + bad.spectralSchemaValidationOutputs.length + ' spectral issue(s) on the broken document; generate and diff also ran'); diff --git a/shared/scripts/check-browser-entry.mjs b/shared/scripts/check-browser-entry.mjs index f4de219d6..b486848e8 100644 --- a/shared/scripts/check-browser-entry.mjs +++ b/shared/scripts/check-browser-entry.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node // Guards the browser entry point: bundles src/browser.ts for the browser, fails on any Node -// builtin request outside the documented allowlist, then executes a probe with those builtins -// stubbed to throw if touched. Run as part of `npm test` (see package.json). +// builtin request outside the documented allowlist, then executes a probe (exercising validate, +// generate and diff) with those builtins stubbed to throw if touched. Run as part of `npm test` +// (see package.json). import * as esbuild from 'esbuild'; import { builtinModules } from 'node:module'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -15,8 +16,9 @@ const repoRoot = path.resolve(sharedRoot, '..'); const builtins = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); // Every Node builtin the browser bundle is allowed to *request* (none may be *touched* at -// runtime on the validate path — the probe proves that). Each entry: builtin + a regex on the -// importer path. Anything else fails the build. Extend only with a matching probe change. +// runtime on the validate, generate or diff paths — the probe proves that). Each entry: builtin +// + a regex on the importer path. Anything else fails the build. Extend only with a matching +// probe change. const ALLOWED = [ { builtin: 'fs', importer: /@stoplight\/spectral-runtime\/dist\/reader\.js$/ }, { builtin: 'fs', importer: /@stoplight\/json-ref-readers\/file\.js$/ }, @@ -68,16 +70,31 @@ async function bundle(entry, outfile, requests) { function checkRequests(requests) { const problems = []; + const matchedAllowed = new Set(); + const sharedSrc = path.join(sharedRoot, 'src'); for (const { builtin, importer } of requests) { const rel = path.relative(repoRoot, importer); - if (importer.startsWith(path.join(sharedRoot, 'src') + path.sep)) { + // Normalise separators before matching so the guard behaves the same on Windows, where + // esbuild's importer paths use backslashes. + const normalizedImporter = importer.replace(/\\/g, '/'); + const relFromSharedSrc = path.relative(sharedSrc, importer); + const isSharedSrc = relFromSharedSrc !== '' && !relFromSharedSrc.startsWith('..') && !path.isAbsolute(relFromSharedSrc); + if (isSharedSrc) { problems.push(`shared source imports Node builtin '${builtin}': ${rel}`); continue; } - if (!ALLOWED.some((a) => a.builtin === builtin && a.importer.test(importer))) { + const allowedIndex = ALLOWED.findIndex((a) => a.builtin === builtin && a.importer.test(normalizedImporter)); + if (allowedIndex === -1) { problems.push(`unexpected Node builtin '${builtin}' requested by ${rel}`); + } else { + matchedAllowed.add(allowedIndex); } } + ALLOWED.forEach((allowed, index) => { + if (!matchedAllowed.has(index)) { + problems.push(`allowlist entry never matched: ${allowed.builtin} <- ${allowed.importer}`); + } + }); return problems; } diff --git a/shared/src/browser-capabilities.spec.ts b/shared/src/browser-capabilities.spec.ts index cf225d394..ec9541ba3 100644 --- a/shared/src/browser-capabilities.spec.ts +++ b/shared/src/browser-capabilities.spec.ts @@ -9,7 +9,9 @@ describe('browser capability manifest', () => { }); it('gives a reason for every unsupported command', () => { - for (const entry of BROWSER_COMMAND_SUPPORT.filter((e) => e.status === 'unsupported')) { + const unsupported = BROWSER_COMMAND_SUPPORT.filter((e) => e.status === 'unsupported'); + expect(unsupported.length).toBeGreaterThan(0); + for (const entry of unsupported) { expect(entry.reason.length).toBeGreaterThan(10); } }); diff --git a/shared/src/browser-capabilities.ts b/shared/src/browser-capabilities.ts index 6a470f7af..c95c88b21 100644 --- a/shared/src/browser-capabilities.ts +++ b/shared/src/browser-capabilities.ts @@ -2,7 +2,9 @@ * Which `calm` CLI commands the browser entry point can honour. Browser consumers (e.g. the * in-browser learning lab) use this to report honestly which commands are available and why the * others are not. `cli/src/browser-manifest.spec.ts` asserts this list matches the commands the - * CLI actually registers, so the two cannot drift. + * CLI actually registers, so the two cannot drift — at top-level commands plus the `hub` + * subgroups' granularity; `workspace` subcommands are covered by the single `workspace` entry, + * not enumerated individually. */ export type BrowserCommandSupport = | { command: string; status: 'supported' } From 76d2ae16b70e14040232a8285f0a8a41b8e946ff Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:50:12 +0100 Subject: [PATCH 22/25] fix(shared): require a colon before an IPv4-mapped tail in the ip literal check The IPV4_TAIL regex is unanchored at the start, so it could partial-match into the middle of a hextet (e.g. matching "1.2.3.4" inside "a1.2.3.4") whenever any colon appeared earlier in the string, wrongly classifying strings like "db8::a1.2.3.4" as valid IPv6. A mapped tail is now only accepted when it is immediately preceded by ':' (or starts the string), matching net.isIP. --- shared/src/util/ip-literal.spec.ts | 1 + shared/src/util/ip-literal.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/shared/src/util/ip-literal.spec.ts b/shared/src/util/ip-literal.spec.ts index 76966a460..5b20915fc 100644 --- a/shared/src/util/ip-literal.spec.ts +++ b/shared/src/util/ip-literal.spec.ts @@ -10,6 +10,7 @@ describe('ipLiteralVersion', () => { ['::ffff:127.0.0.1', 6], ['1.2.3.4:8080', 0], ['fe80::1%eth0', 6], ['01.2.3.4', 0], ['::1:', 0], ['1:2:3:4:5:6:1.2.3.4', 6], ['::ffff:1.2.3.4.5', 0], ['fe80::1%', 0], ['1.2.3.4%eth0', 0], ['::1%25', 6], ['fe80::1%eth0%x', 0], + ['db8::a1.2.3.4', 0], ['::abcd:256.1.1.1', 0], ['::a1.2.3.4', 0], ])('classifies %s as %s', (host, expected) => { expect(ipLiteralVersion(host)).toBe(expected); }); diff --git a/shared/src/util/ip-literal.ts b/shared/src/util/ip-literal.ts index 013ded79f..74d7d3b40 100644 --- a/shared/src/util/ip-literal.ts +++ b/shared/src/util/ip-literal.ts @@ -13,8 +13,10 @@ const ZONE_ID = /^[0-9a-zA-Z.:_-]+$/; function isIPv6Literal(host: string): boolean { let candidate = host; const mapped = candidate.match(IPV4_TAIL); - if (mapped && candidate.lastIndexOf(':') < (mapped.index ?? 0)) { - // IPv4-mapped tail counts as two hextets. + if (mapped && (mapped.index === 0 || candidate[(mapped.index ?? 0) - 1] === ':')) { + // IPv4-mapped tail counts as two hextets. Must be immediately preceded by ':' (or start + // the string) — otherwise the dotted-quad regex may have partial-matched into the middle + // of a hextet (e.g. the "1.2.3.4" inside "a1.2.3.4"), which is not a real mapped tail. candidate = candidate.slice(0, mapped.index) + '0:0'; } const parts = candidate.split('::'); From 7e27d5a76f7636566c6b81677a09a1a86c362ed1 Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:50:16 +0100 Subject: [PATCH 23/25] test(cli): tidy the calm-shared partial mock and state the manifest drift-test granularity hub-commands.spec.ts mocked @finos/calm-shared via three separately-named aliases (documentIdUtils/semver/canonical) that all pointed at the same importActual barrel; collapsed to a single actual spread, with a comment noting the vi.fn(...) overrides must come after it. browser-manifest.spec.ts now documents exactly what granularity the drift check operates at: top-level commands plus the hub subgroups, with workspace subcommands covered by the single workspace entry. --- cli/src/browser-manifest.spec.ts | 3 +++ cli/src/command-helpers/hub-commands.spec.ts | 12 +++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cli/src/browser-manifest.spec.ts b/cli/src/browser-manifest.spec.ts index 1d90cab7c..542d78b26 100644 --- a/cli/src/browser-manifest.spec.ts +++ b/cli/src/browser-manifest.spec.ts @@ -20,6 +20,9 @@ function registeredCommandKeys(): string[] { } describe('browser capability manifest matches the CLI', () => { + // Granularity: top-level commands plus the `hub` subgroups (`hub pull`, `hub list`, ...); + // `workspace` subcommands are intentionally not enumerated, they're covered by the single + // `workspace` entry. it('lists every registered command exactly once', () => { const manifest = BROWSER_COMMAND_SUPPORT.map((entry) => entry.command).sort(); expect(manifest).toEqual(registeredCommandKeys()); diff --git a/cli/src/command-helpers/hub-commands.spec.ts b/cli/src/command-helpers/hub-commands.spec.ts index a099dc853..c862cfbbc 100644 --- a/cli/src/command-helpers/hub-commands.spec.ts +++ b/cli/src/command-helpers/hub-commands.spec.ts @@ -15,9 +15,6 @@ import { runCreateNamespace, runListArchitectures, runListNamespaces, // real (pure) document-id-utils helpers that orchestratePush relies on. vi.mock('@finos/calm-shared', async () => { const actual = await vi.importActual('@finos/calm-shared'); - const documentIdUtils = actual as unknown as Record; - const semver = actual; - const canonical = actual; const mockClient = { createNamespace: vi.fn(), listNamespaces: vi.fn(), @@ -40,10 +37,11 @@ vi.mock('@finos/calm-shared', async () => { createControlConfigurationVersion: vi.fn() }; return { - ...documentIdUtils, - ...semver, - ...canonical, - extractDocumentMetadata: vi.fn(documentIdUtils['extractDocumentMetadata'] as (...args: unknown[]) => unknown), + // Keep all the real (pure) helpers — document-id-utils, semver and canonical — that + // orchestratePush relies on, then override just the HTTP-touching pieces below. The + // vi.fn(...) overrides must come after this spread, or the spread would clobber them. + ...actual, + extractDocumentMetadata: vi.fn(actual.extractDocumentMetadata), CalmHubClient: vi.fn(function () { return mockClient; }), HubClientError: class HubClientError extends Error { constructor(public status: number, public error: string, public request: string) { From 04ebf46cd1da69d4aef6a39d44e2e76681893efa Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:31:35 +0100 Subject: [PATCH 24/25] fix(shared): mark the timeline command unsupported in the browser capability manifest The CLI's timeline command synthesises a timeline from versioned architecture files on the local filesystem, but the browser entry only exports diffTimeline (the diff --timeline core). Correct the manifest so browser consumers report timeline as unsupported instead of claiming it works. --- shared/AGENTS.md | 4 ++-- shared/README.md | 2 +- shared/src/browser-capabilities.spec.ts | 8 +++++++- shared/src/browser-capabilities.ts | 6 +++++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 4d30f8852..c4ffb36ca 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -43,7 +43,7 @@ npx vitest run ${TEST FILE} | Entry | File | Audience | |---|---|---| | `@finos/calm-shared` | `src/index.ts` | CLI, calm-server, anything on Node. Registers winston logging and the JUnit formatter at load. | -| `@finos/calm-shared/browser` | `src/browser.ts` | Browser bundles (the docs learning lab, Studio/Guard). Validate (JSON Schema + Spectral), generate, diff/timeline, `SchemaDirectory`, loaders, the CLI capability manifest. | +| `@finos/calm-shared/browser` | `src/browser.ts` | Browser bundles (the docs learning lab, Studio/Guard). Validate (JSON Schema + Spectral), generate, diff (including `diff --timeline` via `diffTimeline`), `SchemaDirectory`, loaders, the CLI capability manifest. | Rules: - New modules are browser-safe by default. Node-only code (`fs`, `path`, `net`, `process.exit`, `__dirname`, winston, mkdirp, playwright) lives in a `*.node.ts` / `node-*.ts` module or in a wrapper that the root barrel imports — never imported from `browser.ts` or anything it reaches. @@ -51,7 +51,7 @@ Rules: - `scripts/check-browser-entry.mjs` runs in `npm test`. It bundles `src/browser.ts` with esbuild for the browser and fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requests `fs`/`path`/`buffer` but never touches `fs`/`path` at runtime), then executes a real probe (`validate()`, `generate()`, `diffDocuments()`) with those builtins stubbed to throw. Do not extend the allowlist to make a red build green — fix the seam. - Deep imports (`@finos/calm-shared/src/...`, `/dist/...`) are sealed by the `exports` map. Import from the barrel. - Browser consumers bundling the entry must map the allowlisted builtins to nothing — webpack: `resolve.fallback: { fs: false, path: false, buffer: false }`; esbuild: the same stub plugin the guard uses. -- Not in the browser entry (follow-ups): template/docify (filesystem-bound loaders and output strategies), Hub read/write commands (CORS), diagram rasterisation. +- Not in the browser entry (follow-ups): template/docify (filesystem-bound loaders and output strategies), Hub read/write commands (CORS), diagram rasterisation, the standalone `timeline` command (synthesises from versioned architecture files on the local filesystem — `diff --timeline` via `diffTimeline` is supported). ## Key Components diff --git a/shared/README.md b/shared/README.md index c859d4081..ea1d236af 100644 --- a/shared/README.md +++ b/shared/README.md @@ -5,7 +5,7 @@ This module provides shared logic such as validation and visualization utilities ## Browser entry point Browser bundles import from `@finos/calm-shared/browser`, not the package root — the root entry pulls in Node-only code (winston, `fs`, etc.). -The browser entry covers validate (JSON Schema + Spectral), generate, diff/timeline, `SchemaDirectory`, the document loaders, and auth plugins. +The browser entry covers validate (JSON Schema + Spectral), generate, diff (including `diff --timeline` via `diffTimeline`), `SchemaDirectory`, the document loaders, and auth plugins. The standalone `timeline` command is not supported in the browser — it synthesises a timeline from versioned architecture files on the local filesystem. Bundlers must stub out the Node builtins the browser entry's dependency chain still requests but never touches at runtime; for webpack: ```js diff --git a/shared/src/browser-capabilities.spec.ts b/shared/src/browser-capabilities.spec.ts index ec9541ba3..3bb3ba947 100644 --- a/shared/src/browser-capabilities.spec.ts +++ b/shared/src/browser-capabilities.spec.ts @@ -3,11 +3,17 @@ import { BROWSER_COMMAND_SUPPORT, browserSupportFor } from './browser-capabiliti describe('browser capability manifest', () => { it('marks the pure engine commands as supported', () => { - for (const cmd of ['validate', 'generate', 'diff', 'timeline']) { + for (const cmd of ['validate', 'generate', 'diff']) { expect(browserSupportFor(cmd)).toEqual({ command: cmd, status: 'supported' }); } }); + it('marks timeline as unsupported because it synthesises from the local filesystem', () => { + const entry = browserSupportFor('timeline'); + expect(entry?.status).toBe('unsupported'); + expect(entry && 'reason' in entry ? entry.reason : '').toContain('diffTimeline'); + }); + it('gives a reason for every unsupported command', () => { const unsupported = BROWSER_COMMAND_SUPPORT.filter((e) => e.status === 'unsupported'); expect(unsupported.length).toBeGreaterThan(0); diff --git a/shared/src/browser-capabilities.ts b/shared/src/browser-capabilities.ts index c95c88b21..710339e2a 100644 --- a/shared/src/browser-capabilities.ts +++ b/shared/src/browser-capabilities.ts @@ -16,7 +16,11 @@ export const BROWSER_COMMAND_SUPPORT: readonly BrowserCommandSupport[] = [ { command: 'validate', status: 'supported' }, { command: 'generate', status: 'supported' }, { command: 'diff', status: 'supported' }, - { command: 'timeline', status: 'supported' }, + { + command: 'timeline', + status: 'unsupported', + reason: 'synthesises a timeline from versioned architecture files on the local filesystem; timeline diffing is available in the browser through diffTimeline (the diff --timeline core)' + }, { command: 'template', status: 'unsupported', reason: FILESYSTEM_REASON }, { command: 'docify', status: 'unsupported', reason: `${FILESYSTEM_REASON}, and rasterises diagrams with a headless browser` }, { command: 'init-ai', status: 'unsupported', reason: 'installs AI assistant files into the local project' }, From 31fa46ceb79ec7598af3c255912411a6e8d9be8b Mon Sep 17 00:00:00 2001 From: Matthew Bain <66839492+rocketstack-matt@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:31:43 +0100 Subject: [PATCH 25/25] fix(shared): reject redirected responses in the browser document loaders axios's maxRedirects only applies in Node; browsers follow redirects transparently, so a redirect from an allowed origin could be answered by a different one, bypassing DirectUrlDocumentLoader's host allowlist and CalmHubDocumentLoader's configured origin. Revalidate the final response origin (XHR responseURL, fetch Response.url) against the origin requested, and reject the response on a mismatch. --- shared/README.md | 2 + .../calmhub-document-loader.spec.ts | 36 +++++++++++++- .../calmhub-document-loader.ts | 2 + .../direct-url-document-loader.spec.ts | 36 +++++++++++++- .../direct-url-document-loader.ts | 2 + .../document-loader/response-origin.spec.ts | 49 +++++++++++++++++++ shared/src/document-loader/response-origin.ts | 37 ++++++++++++++ 7 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 shared/src/document-loader/response-origin.spec.ts create mode 100644 shared/src/document-loader/response-origin.ts diff --git a/shared/README.md b/shared/README.md index ea1d236af..a4ac7c98e 100644 --- a/shared/README.md +++ b/shared/README.md @@ -18,6 +18,8 @@ The browser entry guard's allowlist assumes bundlers resolve dependencies with t `BROWSER_COMMAND_SUPPORT` (from `browser-capabilities.ts`) lists which `calm` CLI commands the browser entry can honour and why the rest are unsupported there, so consumers can report this to users instead of guessing. +In a browser, `DirectUrlDocumentLoader` and `CalmHubDocumentLoader` also revalidate the final response origin against the one they requested, because the browser follows redirects transparently (axios's `maxRedirects` option only applies in Node) and a redirect from an allowed origin could otherwise be answered by another origin. + # Spectral validation rules for CALM implementations `As of November 2024 - Spectral rules are bundled into shared and converted into typescript representation. ` diff --git a/shared/src/document-loader/calmhub-document-loader.spec.ts b/shared/src/document-loader/calmhub-document-loader.spec.ts index 015a1e55d..5083e9e26 100644 --- a/shared/src/document-loader/calmhub-document-loader.spec.ts +++ b/shared/src/document-loader/calmhub-document-loader.spec.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { Axios } from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { CalmHubDocumentLoader } from './calmhub-document-loader'; import { DocumentLoadError } from './document-loader'; @@ -119,4 +119,38 @@ describe('calmhub-document-loader', () => { await expect(promise).rejects.toBeInstanceOf(DocumentLoadError); await expect(promise).rejects.toThrow('Expected a JSON object'); }); + + it('rejects a response redirected to a different origin', async () => { + const redirectAx = axios.create({ baseURL: calmHubBaseUrl }); + vi.spyOn(redirectAx, 'get').mockResolvedValue({ + data: { '$id': 'https://evil.example/x.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://evil.example/x.json' } + }); + const redirectedLoader = new CalmHubDocumentLoader(calmHubBaseUrl, false, undefined, redirectAx as unknown as Axios); + + const calmHubUrl = 'calm:/schemas/2025-03/meta/core.json'; + await expect(redirectedLoader.loadMissingDocument(calmHubUrl, 'schema')) + .rejects.toThrow('redirected to a different origin'); + }); + + it('accepts a response whose responseURL confirms the same origin', async () => { + const sameOriginAx = axios.create({ baseURL: calmHubBaseUrl }); + vi.spyOn(sameOriginAx, 'get').mockResolvedValue({ + data: { '$id': 'https://calm.finos.org/core.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: `${calmHubBaseUrl}/schemas/2025-03/meta/core.json` } + }); + const sameOriginLoader = new CalmHubDocumentLoader(calmHubBaseUrl, false, undefined, sameOriginAx as unknown as Axios); + + const calmHubUrl = 'calm:/schemas/2025-03/meta/core.json'; + const document = await sameOriginLoader.loadMissingDocument(calmHubUrl, 'schema'); + expect(document).toEqual({ '$id': 'https://calm.finos.org/core.json' }); + }); }); \ No newline at end of file diff --git a/shared/src/document-loader/calmhub-document-loader.ts b/shared/src/document-loader/calmhub-document-loader.ts index f70450ce6..cdab374ec 100644 --- a/shared/src/document-loader/calmhub-document-loader.ts +++ b/shared/src/document-loader/calmhub-document-loader.ts @@ -1,6 +1,7 @@ import axios, { Axios } from 'axios'; import { SchemaDirectory } from '../schema-directory'; import { DocumentLoader, assertJsonObject, DocumentLoadError, CALM_HUB_PROTOS } from './document-loader'; +import { assertResponseOrigin } from './response-origin.js'; import { initLogger, Logger } from '../logger'; import { AuthPlugin } from '../auth/auth-plugin'; import type { CalmDocumentType } from '@finos/calm-models/types'; @@ -120,6 +121,7 @@ export class CalmHubDocumentLoader implements DocumentLoader { try { const response = await this.ax.get(path); + assertResponseOrigin(response, this.calmHubOrigin, documentId); const document = response.data; assertJsonObject(document, documentId); this.logger.debug('Successfully loaded document from CALMHub with id ' + documentId); diff --git a/shared/src/document-loader/direct-url-document-loader.spec.ts b/shared/src/document-loader/direct-url-document-loader.spec.ts index 9065f4523..daf1accf8 100644 --- a/shared/src/document-loader/direct-url-document-loader.spec.ts +++ b/shared/src/document-loader/direct-url-document-loader.spec.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { Axios } from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { DirectUrlDocumentLoader } from './direct-url-document-loader'; import { DocumentLoadError } from './document-loader'; @@ -191,4 +191,38 @@ describe('direct-url-document-loader', () => { await expect(directUrlDocumentLoader.loadMissingDocument(url, 'schema')) .rejects.toThrow('query string'); }); + + it('rejects a response redirected to a different origin', async () => { + const redirectAx = axios.create({}); + vi.spyOn(redirectAx, 'get').mockResolvedValue({ + data: { '$id': 'https://evil.example/x.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://evil.example/x.json' } + }); + const redirectedLoader = new DirectUrlDocumentLoader(false, redirectAx as unknown as Axios); + + const url = 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json'; + await expect(redirectedLoader.loadMissingDocument(url, 'schema')) + .rejects.toThrow('redirected to a different origin'); + }); + + it('accepts a response whose responseURL confirms the same origin', async () => { + const sameOriginAx = axios.create({}); + vi.spyOn(sameOriginAx, 'get').mockResolvedValue({ + data: { '$id': 'https://calm.finos.org/core.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json' } + }); + const sameOriginLoader = new DirectUrlDocumentLoader(false, sameOriginAx as unknown as Axios); + + const url = 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json'; + const document = await sameOriginLoader.loadMissingDocument(url, 'schema'); + expect(document).toEqual({ '$id': 'https://calm.finos.org/core.json' }); + }); }); diff --git a/shared/src/document-loader/direct-url-document-loader.ts b/shared/src/document-loader/direct-url-document-loader.ts index fa5060e31..97625b3f2 100644 --- a/shared/src/document-loader/direct-url-document-loader.ts +++ b/shared/src/document-loader/direct-url-document-loader.ts @@ -2,6 +2,7 @@ import axios, { Axios } from 'axios'; import { ipLiteralVersion } from '../util/ip-literal.js'; import { SchemaDirectory } from '../schema-directory'; import { DocumentLoader, DocumentLoadError, assertJsonObject } from './document-loader'; +import { assertResponseOrigin } from './response-origin.js'; import { Logger, initLogger } from '../logger'; import type { CalmDocumentType } from '@finos/calm-models/types'; @@ -170,6 +171,7 @@ export class DirectUrlDocumentLoader implements DocumentLoader { maxRedirects: 0, allowAbsoluteUrls: false }); + assertResponseOrigin(response, new URL(baseURL).origin, documentId); assertJsonObject(response.data, documentId); return response.data; } catch (error) { diff --git a/shared/src/document-loader/response-origin.spec.ts b/shared/src/document-loader/response-origin.spec.ts new file mode 100644 index 000000000..7730a5e42 --- /dev/null +++ b/shared/src/document-loader/response-origin.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { assertResponseOrigin } from './response-origin.js'; +import { DocumentLoadError } from './document-loader.js'; + +const expectedOrigin = 'https://calm.finos.org'; + +describe('assertResponseOrigin', () => { + it('passes when the response carries no request info (Node http adapter)', () => { + expect(() => assertResponseOrigin({}, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('passes when request.responseURL matches the expected origin (XHR adapter)', () => { + const response = { request: { responseURL: 'https://calm.finos.org/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('passes when request.responseURL is a relative path (mocked/http adapter artifact)', () => { + const response = { request: { responseURL: '/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('throws a non-recoverable DocumentLoadError when responseURL is a different origin', () => { + const response = { request: { responseURL: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).toThrow(DocumentLoadError); + try { + assertResponseOrigin(response, expectedOrigin, 'doc-1'); + throw new Error('expected assertResponseOrigin to throw'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentLoadError); + expect((err as DocumentLoadError).recoverable).toBe(false); + expect((err as DocumentLoadError).message).toContain('redirected to a different origin'); + } + }); + + it('throws when request.url (fetch-style) is a different origin and responseURL is absent', () => { + const response = { request: { url: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).toThrow(DocumentLoadError); + }); + + it('passes when responseURL has a different host case than the expected origin', () => { + const response = { request: { responseURL: 'https://CALM.FINOS.ORG/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('prefers responseURL over url when both are present', () => { + const response = { request: { responseURL: 'https://calm.finos.org/core.json', url: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); +}); diff --git a/shared/src/document-loader/response-origin.ts b/shared/src/document-loader/response-origin.ts new file mode 100644 index 000000000..89c4d28c4 --- /dev/null +++ b/shared/src/document-loader/response-origin.ts @@ -0,0 +1,37 @@ +import { DocumentLoadError } from './document-loader.js'; + +/** + * Browsers follow redirects transparently (axios's maxRedirects applies only in Node), so a request + * to an allowed origin can end up answered by another origin. When the adapter exposes the final URL + * (XHR `responseURL`, fetch `Response.url`), reject a response whose origin differs from the one requested. + */ +export function assertResponseOrigin(response: { request?: unknown }, expectedOrigin: string, documentId: string): void { + const requestInfo = response.request as { responseURL?: unknown; url?: unknown } | undefined; + const responseURL = requestInfo?.responseURL; + const urlField = requestInfo?.url; + + const finalUrl = typeof responseURL === 'string' && responseURL.length > 0 + ? responseURL + : typeof urlField === 'string' && urlField.length > 0 + ? urlField + : undefined; + + if (finalUrl === undefined) { + // Node's http adapter doesn't expose a final URL on the request — nothing to verify. + return; + } + + // Resolve against expectedOrigin rather than parsing finalUrl alone: a real XHR/fetch + // responseURL is always absolute, so the base is ignored and this is equivalent to comparing + // finalUrl's own origin; a relative value (as some test mocks/adapters produce) resolves onto + // the requested origin instead of throwing, which is the correct "no signal" outcome. + const finalOrigin = new URL(finalUrl, expectedOrigin).origin; + + if (finalOrigin.toLowerCase() !== expectedOrigin.toLowerCase()) { + throw new DocumentLoadError({ + name: 'UNKNOWN', + message: `Request for ${documentId} was redirected to a different origin (${finalOrigin}); refusing to use the response.`, + recoverable: false + }); + } +}