diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 49d033b9..94c04032 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,14 +6,19 @@ All packages (`@flyingrobots/bijou`, `@flyingrobots/bijou-node`, `@flyingrobots/ ## [Unreleased] +### Added + +- **VISOR artifact bundle proof** — `@flyingrobots/bijou` now exports + `createVisorArtifactBundleFromGraphql()`, `createVisorArtifactBundle()`, and + `visor-artifact-bundle/1` types for #458 / [`DX-049`](design/DX-049-visor-artifact-bundle-proof.md). + The builder wraps the checked-in DOGFOOD GraphQL fixture path, + `bijou-block/1`, `ui-scene-ir/1`, `graphql-bijou-block-debug/1`, replay + metadata, visual scene facts, raw and semantic source hashes, and stable + receipts while leaving #459 packed-cell validation and external + renderer/debugger work out of scope. + ### Changed -- **VISOR artifact bundle shaping** — - [`DX-049`](design/DX-049-visor-artifact-bundle-proof.md) scopes #458 around a - deterministic `visor-artifact-bundle/1` that wraps the existing GraphQL - fixture path, `bijou-block/1`, `ui-scene-ir/1`, replay metadata, visual scene - facts, and stable hashes while leaving #459 packed-cell validation and - external renderer/debugger work out of scope. - **Future release milestone triage** — The GitHub backlog is now assigned to explicit `v8.0.0`, `v8.1.0`, `v8.2.0`, `v9.0.0`, and `v10.0.0` horizons; `Beyond` and open unmilestoned issue queues are empty; and diff --git a/docs/design/DX-049-visor-artifact-bundle-proof.md b/docs/design/DX-049-visor-artifact-bundle-proof.md index 83a4be9c..9a79b078 100644 --- a/docs/design/DX-049-visor-artifact-bundle-proof.md +++ b/docs/design/DX-049-visor-artifact-bundle-proof.md @@ -164,9 +164,14 @@ visor-artifact-bundle/1 receipts[] ``` -`bundleHash` is computed from the canonical bundle payload with the hash field -itself omitted. Hashing must use the same deterministic JSON rules already used -by the GraphQL block and scene IR proof path. +`sourceHash` records the raw source text hash. `normalizedSourceHash` records +the semantic source hash inherited from the compiled `bijou-block/1` artifact. +`bundleHash` is computed from the canonical semantic bundle payload with raw +`source.text`, raw `sourceHash` copies, and `bundleHash` itself omitted. That +lets VISOR inspect exact source text and raw source identity while preserving a +stable semantic bundle hash across whitespace-only SDL edits. Hashing must use +the same deterministic JSON rules already used by the GraphQL block and scene +IR proof path. ## Replay Metadata Contract @@ -298,10 +303,14 @@ An agent should be able to answer, from the bundle alone: ## Validation Plan -Current design PR validation: +Current implementation validation: ```bash -npx vitest run --config vitest.config.ts tests/cycles/WF-130/roadmap-goalpost-policy.part01.test.ts tests/cycles/WF-130/roadmap-goalpost-policy.part02.test.ts tests/cycles/WF-130/roadmap-goalpost-policy.part04.test.ts +npx vitest run --config vitest.config.ts tests/cycles/DX-049 +npx vitest run --config vitest.config.ts \ + tests/cycles/WF-130/roadmap-goalpost-policy.part01.test.ts \ + tests/cycles/WF-130/roadmap-goalpost-policy.part02.test.ts \ + tests/cycles/WF-130/roadmap-goalpost-policy.part04.test.ts npm run docs:inventory npm run typecheck:test npm run code-dojo:changed @@ -309,15 +318,12 @@ npm run lint git diff --check ``` -Future implementation validation after the RED-test slice creates -`tests/cycles/DX-049`: - -```bash -npx vitest run --config vitest.config.ts tests/cycles/DX-049 -``` - ## Closeout Notes -Open. This design starts #458. Keep `needs-design` on the issue until the -design PR lands; keep `work-in-progress` on the issue while the branch and PR -carry the cycle. +Implementation in progress. The design PR has landed, so #458 no longer needs +`needs-design`. The implementation branch now carries focused +`tests/cycles/DX-049` coverage for the public bundle builder, exported bundle +type, raw-versus-semantic source hashes, replay metadata, visual scene facts, +absolute source-name rejection, malformed source rejection, duplicate visual +node rejection, and hash-mismatch rejection. Keep `work-in-progress` on the +issue while the branch and PR carry the implementation cycle. diff --git a/packages/bijou/src/core/visor-artifact-bundle.part01.ts b/packages/bijou/src/core/visor-artifact-bundle.part01.ts new file mode 100644 index 00000000..c6f219f8 --- /dev/null +++ b/packages/bijou/src/core/visor-artifact-bundle.part01.ts @@ -0,0 +1,141 @@ +import type { + UiNodeKind, + UiSceneIr, + UiSceneLowerMode, + UiSceneLoweringOptions, + UiTargetProfile, + UiTextRef, +} from './ui-scene-ir.js'; +import type { + BijouBlockArtifact, + GraphqlBijouBlockDebugSummary, +} from './graphql-bijou-block.part01.js'; + +export const VISOR_ARTIFACT_BUNDLE_VERSION = 'visor-artifact-bundle/1' as const; +export const VISOR_REPLAY_METADATA_VERSION = 'visor-replay-metadata/1' as const; +export const VISOR_VISUAL_SCENE_FACTS_VERSION = 'visor-visual-scene-facts/1' as const; + +export type VisorArtifactBundleVersion = typeof VISOR_ARTIFACT_BUNDLE_VERSION; +export type VisorReplayMetadataVersion = typeof VISOR_REPLAY_METADATA_VERSION; +export type VisorVisualSceneFactsVersion = typeof VISOR_VISUAL_SCENE_FACTS_VERSION; + +export interface CreateVisorArtifactBundleFromGraphqlOptions extends UiSceneLoweringOptions { + readonly fixtureId: string; + readonly sourceName: string; + readonly scenarioId?: string; + readonly deterministicSeed?: string; +} + +export interface CreateVisorArtifactBundleInput extends CreateVisorArtifactBundleFromGraphqlOptions { + readonly sourceText: string; + readonly bijouBlock: BijouBlockArtifact; + readonly uiScene: UiSceneIr; + readonly debugSummary: GraphqlBijouBlockDebugSummary; +} + +export interface VisorArtifactBundleFixture { + readonly id: string; + readonly sourceName: string; + readonly sourceHash: string; + readonly normalizedSourceHash: string; +} + +export interface VisorArtifactBundleSource { + readonly language: 'graphql'; + readonly text: string; +} + +export interface VisorArtifactBundleArtifacts { + readonly bijouBlock: BijouBlockArtifact; + readonly uiScene: UiSceneIr; + readonly debugSummary: GraphqlBijouBlockDebugSummary; +} + +export interface VisorReplayStep { + readonly id: string; + readonly label: string; + readonly inputHashes: readonly string[]; + readonly outputHashes: readonly string[]; +} + +export interface VisorReplayMetadata { + readonly replayVersion: VisorReplayMetadataVersion; + readonly scenarioId: string; + readonly fixtureId: string; + readonly deterministicSeed: string; + readonly consumedHashes: { + readonly sourceHash: string; + readonly normalizedSourceHash: string; + readonly artifactHash: string; + readonly sceneHash: string; + readonly debugSummaryHash: string; + readonly visualFactsHash: string; + }; + readonly steps: readonly VisorReplayStep[]; +} + +export interface VisorVisualNodeFact { + readonly nodeId: string; + readonly kind: UiNodeKind; + readonly role?: string; + readonly component?: string; + readonly parentId?: string; + readonly childNodeIds: readonly string[]; + readonly text?: UiTextRef; + readonly i18nKeys: readonly string[]; + readonly tokenRefs: readonly string[]; + readonly actionIds: readonly string[]; + readonly bindingIds: readonly string[]; + readonly sourceRefs: readonly string[]; +} + +export interface VisorVisualLowerModeFact { + readonly mode: UiSceneLowerMode; + readonly surfaceHash: string; + readonly rowsHash: string; +} + +export interface VisorVisualSceneFacts { + readonly visualFactsVersion: VisorVisualSceneFactsVersion; + readonly sceneId: string; + readonly rootNodeId: string; + readonly targetProfiles: readonly UiTargetProfile[]; + readonly nodeFacts: readonly VisorVisualNodeFact[]; + readonly lowerModes: readonly VisorVisualLowerModeFact[]; +} + +export interface VisorArtifactBundleHashes { + readonly sourceHash: string; + readonly normalizedSourceHash: string; + readonly artifactHash: string; + readonly sceneHash: string; + readonly debugSummaryHash: string; + readonly replayHash: string; + readonly visualFactsHash: string; + readonly bundleHash: string; +} + +export interface VisorArtifactBundleReceipt { + readonly subject: string; + readonly version: string; + readonly hash: string; +} + +export interface VisorArtifactBundle { + readonly bundleVersion: VisorArtifactBundleVersion; + readonly fixture: VisorArtifactBundleFixture; + readonly source: VisorArtifactBundleSource; + readonly artifacts: VisorArtifactBundleArtifacts; + readonly replay: VisorReplayMetadata; + readonly visual: VisorVisualSceneFacts; + readonly hashes: VisorArtifactBundleHashes; + readonly receipts: readonly VisorArtifactBundleReceipt[]; +} + +export type VisorArtifactBundleHashBody = Omit; + +export type VisorArtifactBundleWithoutHash = Omit & { + readonly hashes: VisorArtifactBundleHashBody & { + readonly bundleHash?: string; + }; +}; diff --git a/packages/bijou/src/core/visor-artifact-bundle.part02.ts b/packages/bijou/src/core/visor-artifact-bundle.part02.ts new file mode 100644 index 00000000..a96c1dee --- /dev/null +++ b/packages/bijou/src/core/visor-artifact-bundle.part02.ts @@ -0,0 +1,111 @@ +import { hashUiSceneValue } from './ui-scene-ir.js'; +import { compileGraphqlBijouBlock } from './graphql-bijou-block.part02.js'; +import { lowerBijouBlockToUiScene } from './graphql-bijou-block.part03.js'; +import { createGraphqlBijouBlockDebugSummary } from './graphql-bijou-block.part04.js'; +import { normalizeSourceName } from './graphql-bijou-block.part10.js'; +import { + VISOR_ARTIFACT_BUNDLE_VERSION, + type CreateVisorArtifactBundleFromGraphqlOptions, + type CreateVisorArtifactBundleInput, + type VisorArtifactBundle, + type VisorArtifactBundleHashBody, +} from './visor-artifact-bundle.part01.js'; +import { + createVisorReplayMetadata, + createVisorVisualSceneFacts, +} from './visor-artifact-bundle.part03.js'; +import { + assertDebugSummaryMatchesFacts, + assertSceneMatchesArtifact, + createReceipts, + normalizeLogicalId, + semanticBundleHashPayload, +} from './visor-artifact-bundle.part04.js'; + +export function createVisorArtifactBundleFromGraphql( + source: string, + options: CreateVisorArtifactBundleFromGraphqlOptions, +): VisorArtifactBundle { + const sourceName = normalizeSourceName(options.sourceName); + const bijouBlock = compileGraphqlBijouBlock(source, { sourceName }); + const uiScene = lowerBijouBlockToUiScene(bijouBlock); + const debugSummary = createGraphqlBijouBlockDebugSummary(bijouBlock, options); + return createVisorArtifactBundle({ + ...options, + sourceName, + sourceText: source, + bijouBlock, + uiScene, + debugSummary, + }); +} + +export function createVisorArtifactBundle(input: CreateVisorArtifactBundleInput): VisorArtifactBundle { + const fixtureId = normalizeLogicalId(input.fixtureId, 'fixtureId'); + const scenarioId = normalizeLogicalId(input.scenarioId ?? `${fixtureId}:graphql-to-ui-scene`, 'scenarioId'); + const deterministicSeed = input.deterministicSeed ?? 'no-randomness'; + const sourceName = normalizeSourceName(input.sourceName); + + if (input.bijouBlock.sourceName !== sourceName) { + throw new Error('visor-artifact-bundle/1 sourceName does not match bijou-block/1.'); + } + + const visual = createVisorVisualSceneFacts(input.uiScene, input.debugSummary); + const visualFactsHash = hashUiSceneValue(visual); + assertSceneMatchesArtifact(input.bijouBlock, input.uiScene); + assertDebugSummaryMatchesFacts(input.bijouBlock, input.uiScene, input.debugSummary); + + const sourceHash = hashUiSceneValue(input.sourceText); + const normalizedSourceHash = input.bijouBlock.sourceHash; + const artifactHash = hashUiSceneValue(input.bijouBlock); + const sceneHash = hashUiSceneValue(input.uiScene); + const debugSummaryHash = input.debugSummary.summaryHash; + const replay = createVisorReplayMetadata({ + fixtureId, + scenarioId, + deterministicSeed, + sourceHash, + normalizedSourceHash, + artifactHash, + sceneHash, + debugSummaryHash, + visualFactsHash, + }); + const replayHash = hashUiSceneValue(replay); + const hashes: VisorArtifactBundleHashBody = { + sourceHash, + normalizedSourceHash, + artifactHash, + sceneHash, + debugSummaryHash, + replayHash, + visualFactsHash, + }; + const bundleWithoutHash = { + bundleVersion: VISOR_ARTIFACT_BUNDLE_VERSION, + fixture: { id: fixtureId, sourceName, sourceHash, normalizedSourceHash }, + source: { language: 'graphql' as const, text: input.sourceText }, + artifacts: { + bijouBlock: input.bijouBlock, + uiScene: input.uiScene, + debugSummary: input.debugSummary, + }, + replay, + visual, + hashes: { ...hashes, bundleHash: undefined }, + receipts: createReceipts({ + artifactHash, + sceneHash, + debugSummaryHash, + replayHash, + visualFactsHash, + }), + }; + return { + ...bundleWithoutHash, + hashes: { + ...hashes, + bundleHash: hashUiSceneValue(semanticBundleHashPayload(bundleWithoutHash)), + }, + }; +} diff --git a/packages/bijou/src/core/visor-artifact-bundle.part03.ts b/packages/bijou/src/core/visor-artifact-bundle.part03.ts new file mode 100644 index 00000000..6dd6fd7b --- /dev/null +++ b/packages/bijou/src/core/visor-artifact-bundle.part03.ts @@ -0,0 +1,133 @@ +import { hashUiSceneValue, type UiNodeKind, type UiSceneIr, type UiTextRef } from './ui-scene-ir.js'; +import type { GraphqlBijouBlockDebugSummary } from './graphql-bijou-block.part01.js'; +import { + VISOR_REPLAY_METADATA_VERSION, + VISOR_VISUAL_SCENE_FACTS_VERSION, + type VisorReplayMetadata, + type VisorReplayStep, + type VisorVisualSceneFacts, +} from './visor-artifact-bundle.part01.js'; +import { + assertRequiredLowerModes, + assertUniqueVisualNodeIds, + sortedUniqueStrings, +} from './visor-artifact-bundle.part04.js'; + +type HashList = readonly string[]; + +export function createVisorVisualSceneFacts( + scene: UiSceneIr, + debugSummary: GraphqlBijouBlockDebugSummary, +): VisorVisualSceneFacts { + assertUniqueVisualNodeIds(scene); + assertRequiredLowerModes(debugSummary); + return { + visualFactsVersion: VISOR_VISUAL_SCENE_FACTS_VERSION, + sceneId: scene.id, + rootNodeId: scene.rootNodeId, + targetProfiles: scene.targetProfiles, + nodeFacts: scene.nodes.map((node) => { + const fact: { + nodeId: string; + kind: UiNodeKind; + role?: string; + component?: string; + parentId?: string; + childNodeIds: readonly string[]; + text?: UiTextRef; + i18nKeys: readonly string[]; + tokenRefs: readonly string[]; + actionIds: readonly string[]; + bindingIds: readonly string[]; + sourceRefs: readonly string[]; + } = { + nodeId: node.id, + kind: node.kind, + childNodeIds: node.children ?? [], + i18nKeys: sortedUniqueStrings(scene.i18nUses + .filter((use) => use.nodeId === node.id) + .map((use) => use.key)), + tokenRefs: sortedUniqueStrings(scene.tokenUses + .filter((use) => use.nodeId === node.id) + .map((use) => use.token)), + actionIds: sortedUniqueStrings([ + ...(node.actions ?? []), + ...scene.actions.filter((action) => action.targetNodeId === node.id).map((action) => action.id), + ]), + bindingIds: sortedUniqueStrings(scene.bindings + .filter((binding) => binding.targetNodeId === node.id) + .map((binding) => binding.id)), + sourceRefs: sortedUniqueStrings(scene.sourceMap + .filter((entry) => entry.nodeId === node.id) + .map((entry) => entry.source)), + }; + if (node.role != null) fact.role = node.role; + if (node.component != null) fact.component = node.component; + if (node.parentId != null) fact.parentId = node.parentId; + if (node.text != null) fact.text = node.text; + return fact; + }), + lowerModes: debugSummary.lowerModes.map((lowerMode) => ({ + mode: lowerMode.mode, + surfaceHash: lowerMode.surfaceHash, + rowsHash: hashUiSceneValue(lowerMode.rows), + })), + }; +} + +export function createVisorReplayMetadata(input: { + readonly fixtureId: string; + readonly scenarioId: string; + readonly deterministicSeed: string; + readonly sourceHash: string; + readonly normalizedSourceHash: string; + readonly artifactHash: string; + readonly sceneHash: string; + readonly debugSummaryHash: string; + readonly visualFactsHash: string; +}): VisorReplayMetadata { + return { + replayVersion: VISOR_REPLAY_METADATA_VERSION, + scenarioId: input.scenarioId, + fixtureId: input.fixtureId, + deterministicSeed: input.deterministicSeed, + consumedHashes: { + sourceHash: input.normalizedSourceHash, + normalizedSourceHash: input.normalizedSourceHash, + artifactHash: input.artifactHash, + sceneHash: input.sceneHash, + debugSummaryHash: input.debugSummaryHash, + visualFactsHash: input.visualFactsHash, + }, + steps: [ + replayStep( + 'compile-graphql-bijou-block', + 'Compile GraphQL SDL into bijou-block/1.', + [input.normalizedSourceHash], + [input.artifactHash], + ), + replayStep( + 'lower-bijou-block-to-ui-scene', + 'Lower bijou-block/1 into ui-scene-ir/1.', + [input.artifactHash], + [input.sceneHash], + ), + replayStep( + 'summarize-graphql-bijou-block-debug', + 'Summarize grouped GraphQL block debug facts.', + [input.artifactHash, input.sceneHash], + [input.debugSummaryHash], + ), + replayStep( + 'extract-visual-scene-facts', + 'Extract VISOR visual scene facts from ui-scene-ir/1.', + [input.sceneHash, input.debugSummaryHash], + [input.visualFactsHash], + ), + ], + }; +} + +function replayStep(id: string, label: string, inputHashes: HashList, outputHashes: HashList): VisorReplayStep { + return { id, label, inputHashes, outputHashes }; +} diff --git a/packages/bijou/src/core/visor-artifact-bundle.part04.ts b/packages/bijou/src/core/visor-artifact-bundle.part04.ts new file mode 100644 index 00000000..55715a42 --- /dev/null +++ b/packages/bijou/src/core/visor-artifact-bundle.part04.ts @@ -0,0 +1,120 @@ +import { hashUiSceneValue, type UiSceneIr, type UiSceneLowerMode } from './ui-scene-ir.js'; +import { lowerBijouBlockToUiScene } from './graphql-bijou-block.part03.js'; +import type { + BijouBlockArtifact, + GraphqlBijouBlockDebugSummary, +} from './graphql-bijou-block.part01.js'; +import { + VISOR_REPLAY_METADATA_VERSION, + VISOR_VISUAL_SCENE_FACTS_VERSION, + type VisorArtifactBundleReceipt, + type VisorArtifactBundleWithoutHash, +} from './visor-artifact-bundle.part01.js'; + +const REQUIRED_LOWER_MODES: readonly UiSceneLowerMode[] = [ + 'normal', + 'node-ids', + 'i18n-keys', + 'token-refs', +]; + +export function assertSceneMatchesArtifact(artifact: BijouBlockArtifact, scene: UiSceneIr): void { + if (hashUiSceneValue(lowerBijouBlockToUiScene(artifact)) !== hashUiSceneValue(scene)) { + throw new Error('visor-artifact-bundle/1 ui-scene-ir/1 does not match bijou-block/1.'); + } +} + +export function assertDebugSummaryMatchesFacts( + artifact: BijouBlockArtifact, + scene: UiSceneIr, + debugSummary: GraphqlBijouBlockDebugSummary, +): void { + if (debugSummary.artifactHash !== hashUiSceneValue(artifact)) { + throw new Error('visor-artifact-bundle/1 debug summary artifact hash does not match bijou-block/1.'); + } + if (debugSummary.sceneHash !== hashUiSceneValue(scene)) { + throw new Error('visor-artifact-bundle/1 debug summary scene hash does not match ui-scene-ir/1.'); + } + if (debugSummary.summaryHash !== hashUiSceneValue({ ...debugSummary, summaryHash: undefined })) { + throw new Error('visor-artifact-bundle/1 debug summary hash does not match summary payload.'); + } +} + +export function assertUniqueVisualNodeIds(scene: UiSceneIr): void { + const seen = new Set(); + for (const node of scene.nodes) { + if (seen.has(node.id)) { + throw new Error(`visor-artifact-bundle/1 visual facts contain duplicate node id: ${node.id}`); + } + seen.add(node.id); + } +} + +export function assertRequiredLowerModes(debugSummary: GraphqlBijouBlockDebugSummary): void { + const available = new Set(debugSummary.lowerModes.map((lowerMode) => lowerMode.mode)); + for (const required of REQUIRED_LOWER_MODES) { + if (!available.has(required)) { + throw new Error(`visor-artifact-bundle/1 missing lower-mode witness: ${required}`); + } + } +} + +export function semanticBundleHashPayload(bundle: VisorArtifactBundleWithoutHash): unknown { + return { + ...bundle, + fixture: { + ...bundle.fixture, + sourceHash: undefined, + }, + source: { + ...bundle.source, + text: undefined, + }, + hashes: { + ...bundle.hashes, + sourceHash: undefined, + bundleHash: undefined, + }, + }; +} + +export function normalizeLogicalId(value: string, label: 'fixtureId' | 'scenarioId'): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`visor-artifact-bundle/1 ${label} cannot be empty.`); + } + if ( + trimmed.startsWith('/') + || trimmed.startsWith('\\\\') + || trimmed.startsWith('//') + || /^[A-Za-z]:[\\/]/.test(trimmed) + || trimmed.includes('\0') + ) { + throw new Error(`visor-artifact-bundle/1 ${label} must be a logical id.`); + } + return trimmed; +} + +export function createReceipts(input: { + readonly artifactHash: string; + readonly sceneHash: string; + readonly debugSummaryHash: string; + readonly replayHash: string; + readonly visualFactsHash: string; +}): readonly VisorArtifactBundleReceipt[] { + return [ + { subject: 'artifacts.bijouBlock', version: 'bijou-block/1', hash: input.artifactHash }, + { subject: 'artifacts.uiScene', version: 'ui-scene-ir/1', hash: input.sceneHash }, + { subject: 'artifacts.debugSummary', version: 'graphql-bijou-block-debug/1', hash: input.debugSummaryHash }, + { subject: 'replay', version: VISOR_REPLAY_METADATA_VERSION, hash: input.replayHash }, + { subject: 'visual', version: VISOR_VISUAL_SCENE_FACTS_VERSION, hash: input.visualFactsHash }, + ]; +} + +export function sortedUniqueStrings(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(compareCodeUnits); +} + +function compareCodeUnits(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} diff --git a/packages/bijou/src/core/visor-artifact-bundle.ts b/packages/bijou/src/core/visor-artifact-bundle.ts new file mode 100644 index 00000000..c139d7b8 --- /dev/null +++ b/packages/bijou/src/core/visor-artifact-bundle.ts @@ -0,0 +1,27 @@ +export { + VISOR_ARTIFACT_BUNDLE_VERSION, + VISOR_REPLAY_METADATA_VERSION, + VISOR_VISUAL_SCENE_FACTS_VERSION, +} from './visor-artifact-bundle.part01.js'; +export type { + CreateVisorArtifactBundleFromGraphqlOptions, + CreateVisorArtifactBundleInput, + VisorArtifactBundle, + VisorArtifactBundleArtifacts, + VisorArtifactBundleFixture, + VisorArtifactBundleHashes, + VisorArtifactBundleReceipt, + VisorArtifactBundleSource, + VisorArtifactBundleVersion, + VisorReplayMetadata, + VisorReplayMetadataVersion, + VisorReplayStep, + VisorVisualLowerModeFact, + VisorVisualNodeFact, + VisorVisualSceneFacts, + VisorVisualSceneFactsVersion, +} from './visor-artifact-bundle.part01.js'; +export { + createVisorArtifactBundle, + createVisorArtifactBundleFromGraphql, +} from './visor-artifact-bundle.part02.js'; diff --git a/packages/bijou/src/index.part02.ts b/packages/bijou/src/index.part02.ts index 6385ddf6..5a2c3af1 100644 --- a/packages/bijou/src/index.part02.ts +++ b/packages/bijou/src/index.part02.ts @@ -70,6 +70,30 @@ export { type GraphqlBijouBlockDebugSummaryVersion, } from './core/graphql-bijou-block.js'; +export { + VISOR_ARTIFACT_BUNDLE_VERSION, + VISOR_REPLAY_METADATA_VERSION, + VISOR_VISUAL_SCENE_FACTS_VERSION, + createVisorArtifactBundle, + createVisorArtifactBundleFromGraphql, + type CreateVisorArtifactBundleFromGraphqlOptions, + type CreateVisorArtifactBundleInput, + type VisorArtifactBundle, + type VisorArtifactBundleArtifacts, + type VisorArtifactBundleFixture, + type VisorArtifactBundleHashes, + type VisorArtifactBundleReceipt, + type VisorArtifactBundleSource, + type VisorArtifactBundleVersion, + type VisorReplayMetadata, + type VisorReplayMetadataVersion, + type VisorReplayStep, + type VisorVisualLowerModeFact, + type VisorVisualNodeFact, + type VisorVisualSceneFacts, + type VisorVisualSceneFactsVersion, +} from './core/visor-artifact-bundle.js'; + export { UI_SCENE_IR_VERSION, UI_SCENE_RECEIPT_VERSION, diff --git a/tests/cycles/DX-049/visor-artifact-bundle.part01.test.ts b/tests/cycles/DX-049/visor-artifact-bundle.part01.test.ts new file mode 100644 index 00000000..ef7cc4fc --- /dev/null +++ b/tests/cycles/DX-049/visor-artifact-bundle.part01.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + VISOR_ARTIFACT_BUNDLE_VERSION, + hashUiSceneValue, +} from '@flyingrobots/bijou'; +import { + buildNavigationBundle, + expectedSemanticBundleHash, + FIXTURE_ID, + FIXTURE_PATH, + navigationSource, +} from './visor-artifact-bundle.test-support.js'; + +describe('DX-049 visor-artifact-bundle/1', () => { + it('wraps the DOGFOOD GraphQL fixture, block artifact, scene IR, and debug summary', () => { + const bundle = buildNavigationBundle(navigationSource()); + + expect(bundle.bundleVersion).toBe(VISOR_ARTIFACT_BUNDLE_VERSION); + expect(bundle.fixture).toMatchObject({ + id: FIXTURE_ID, + sourceName: FIXTURE_PATH, + }); + expect(bundle.source).toEqual({ + language: 'graphql', + text: bundle.source.text, + }); + expect(bundle.source.text).toContain('type DogfoodNavigationList'); + expect(bundle.artifacts.bijouBlock).toMatchObject({ + artifactVersion: 'bijou-block/1', + id: 'dogfood.navigation', + component: 'NavigationListBlock', + sourceName: FIXTURE_PATH, + }); + expect(bundle.artifacts.uiScene).toMatchObject({ + irVersion: 'ui-scene-ir/1', + id: 'dogfood.navigation', + rootNodeId: 'dogfood.navigation.root', + }); + expect(bundle.artifacts.debugSummary).toMatchObject({ + summaryVersion: 'graphql-bijou-block-debug/1', + artifactId: 'dogfood.navigation', + rootNodeId: 'dogfood.navigation.root', + }); + expect(bundle.hashes).toMatchObject({ + sourceHash: hashUiSceneValue(bundle.source.text), + normalizedSourceHash: bundle.fixture.normalizedSourceHash, + artifactHash: hashUiSceneValue(bundle.artifacts.bijouBlock), + sceneHash: hashUiSceneValue(bundle.artifacts.uiScene), + debugSummaryHash: bundle.artifacts.debugSummary.summaryHash, + replayHash: hashUiSceneValue(bundle.replay), + visualFactsHash: hashUiSceneValue(bundle.visual), + }); + expect(bundle.hashes.bundleHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(bundle.hashes.bundleHash).toBe(expectedSemanticBundleHash(bundle)); + }); + + it('keeps semantic hashes stable across whitespace-only SDL edits', () => { + const source = navigationSource(); + const whitespaceOnlyVariant = source + .replace('type DogfoodNavigationList', 'type DogfoodNavigationList') + .replace( + '@bijouBlock(id: "dogfood.navigation", component: "NavigationListBlock")', + '@bijouBlock( id: "dogfood.navigation", component: "NavigationListBlock" )', + ) + .replace( + '@bijouTarget(kind: "bijou-terminal", cols: 80, rows: 8)', + '@bijouTarget( kind: "bijou-terminal", cols: 80, rows: 8 )', + ); + + const one = buildNavigationBundle(source); + const two = buildNavigationBundle(whitespaceOnlyVariant); + + expect(one.hashes.sourceHash).not.toBe(two.hashes.sourceHash); + expect(one.hashes.normalizedSourceHash).toBe(two.hashes.normalizedSourceHash); + expect(one.hashes.artifactHash).toBe(two.hashes.artifactHash); + expect(one.hashes.sceneHash).toBe(two.hashes.sceneHash); + expect(one.hashes.debugSummaryHash).toBe(two.hashes.debugSummaryHash); + expect(one.hashes.visualFactsHash).toBe(two.hashes.visualFactsHash); + expect(one.hashes.replayHash).toBe(two.hashes.replayHash); + expect(one.hashes.bundleHash).toBe(two.hashes.bundleHash); + }); +}); diff --git a/tests/cycles/DX-049/visor-artifact-bundle.part02.test.ts b/tests/cycles/DX-049/visor-artifact-bundle.part02.test.ts new file mode 100644 index 00000000..b2fc196a --- /dev/null +++ b/tests/cycles/DX-049/visor-artifact-bundle.part02.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + compileGraphqlBijouBlock, + createGraphqlBijouBlockDebugSummary, + createVisorArtifactBundle, + createVisorArtifactBundleFromGraphql, + lowerBijouBlockToUiScene, +} from '@flyingrobots/bijou'; +import { + buildNavigationBundle, + FIXTURE_ID, + FIXTURE_PATH, + navigationSource, + NAV_TOKEN_COLORS, +} from './visor-artifact-bundle.test-support.js'; + +describe('DX-049 visor-artifact-bundle/1', () => { + it('exposes replay identity and visual scene facts without host-local state', () => { + const bundle = buildNavigationBundle(); + + expect(bundle.replay).toMatchObject({ + replayVersion: 'visor-replay-metadata/1', + scenarioId: `${FIXTURE_ID}:graphql-to-ui-scene`, + fixtureId: FIXTURE_ID, + deterministicSeed: 'no-randomness', + consumedHashes: { + artifactHash: bundle.hashes.artifactHash, + sceneHash: bundle.hashes.sceneHash, + debugSummaryHash: bundle.hashes.debugSummaryHash, + visualFactsHash: bundle.hashes.visualFactsHash, + }, + }); + expect(bundle.replay.steps.map((step) => step.id)).toEqual([ + 'compile-graphql-bijou-block', + 'lower-bijou-block-to-ui-scene', + 'summarize-graphql-bijou-block-debug', + 'extract-visual-scene-facts', + ]); + expect(JSON.stringify(bundle.replay)).not.toMatch(/\/Users\/|[A-Za-z]:\\|pid|timestamp|terminalDimensions/); + + expect(bundle.visual).toMatchObject({ + visualFactsVersion: 'visor-visual-scene-facts/1', + sceneId: 'dogfood.navigation', + rootNodeId: 'dogfood.navigation.root', + targetProfiles: [{ kind: 'bijou-terminal', cols: 80, rows: 8 }], + }); + expect(bundle.visual.nodeFacts.find((fact) => fact.nodeId === 'dogfood.navigation.active')).toMatchObject({ + nodeId: 'dogfood.navigation.active', + kind: 'text', + i18nKeys: ['dogfood.navigation.active'], + tokenRefs: ['semantic.nav.item.active.bg', 'semantic.nav.item.active.fg'], + actionIds: ['navigation.selectItem'], + bindingIds: ['navigation.selection.activeLabel'], + sourceRefs: [`${FIXTURE_PATH}#type.DogfoodNavigationList.field.activeItem`], + }); + expect(bundle.visual.lowerModes.map((mode) => mode.mode)).toEqual([ + 'normal', + 'node-ids', + 'i18n-keys', + 'token-refs', + ]); + }); + + it('rejects invalid identity, source, duplicate node, and hash facts', () => { + const source = navigationSource(); + + expect(() => createVisorArtifactBundleFromGraphql(source, { + fixtureId: '', + sourceName: FIXTURE_PATH, + })).toThrow('visor-artifact-bundle/1 fixtureId cannot be empty.'); + expect(() => createVisorArtifactBundleFromGraphql(source, { + fixtureId: FIXTURE_ID, + sourceName: '/tmp/navigation-list.graphql', + })).toThrow('GraphQL Bijou block sourceName must be a relative or logical name.'); + expect(() => createVisorArtifactBundleFromGraphql( + source.replace('\n @bijouI18n(key: "dogfood.navigation.itemCount", fallback: "Items: 7")', ''), + { + fixtureId: FIXTURE_ID, + sourceName: FIXTURE_PATH, + }, + )).toThrow('GraphQL Bijou block field itemCount must include @bijouI18n(...).'); + + const artifact = compileGraphqlBijouBlock(source, { sourceName: FIXTURE_PATH }); + const scene = lowerBijouBlockToUiScene(artifact); + const debugSummary = createGraphqlBijouBlockDebugSummary(artifact, { + tokenColors: NAV_TOKEN_COLORS, + }); + + expect(() => createVisorArtifactBundle({ + fixtureId: FIXTURE_ID, + sourceName: FIXTURE_PATH, + sourceText: source, + bijouBlock: artifact, + uiScene: { + ...scene, + nodes: [...scene.nodes, { ...scene.nodes[0], id: 'dogfood.navigation.active' }], + }, + debugSummary, + tokenColors: NAV_TOKEN_COLORS, + })).toThrow('visor-artifact-bundle/1 visual facts contain duplicate node id: dogfood.navigation.active'); + + expect(() => createVisorArtifactBundle({ + fixtureId: FIXTURE_ID, + sourceName: FIXTURE_PATH, + sourceText: source, + bijouBlock: { + ...artifact, + sourceHash: 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + }, + uiScene: scene, + debugSummary, + tokenColors: NAV_TOKEN_COLORS, + })).toThrow('visor-artifact-bundle/1 ui-scene-ir/1 does not match bijou-block/1.'); + }); +}); diff --git a/tests/cycles/DX-049/visor-artifact-bundle.test-support.ts b/tests/cycles/DX-049/visor-artifact-bundle.test-support.ts new file mode 100644 index 00000000..fc0e1c32 --- /dev/null +++ b/tests/cycles/DX-049/visor-artifact-bundle.test-support.ts @@ -0,0 +1,48 @@ +import { + createVisorArtifactBundleFromGraphql, + hashUiSceneValue, + type VisorArtifactBundle, +} from '@flyingrobots/bijou'; +import { readRepoFile } from '../repo.js'; + +export const FIXTURE_ID = 'dogfood.navigation.graphql'; +export const FIXTURE_PATH = 'examples/docs/fixtures/graphql/navigation-list.graphql'; + +export const NAV_TOKEN_COLORS = { + 'semantic.nav.hint.fg': '#8aa4ff', + 'semantic.nav.item.active.bg': '#1f2937', + 'semantic.nav.item.active.fg': '#f9fafb', + 'semantic.nav.item.fg': '#d1d5db', + 'semantic.nav.title.fg': '#f7d774', +}; + +export function navigationSource(): string { + return readRepoFile(FIXTURE_PATH); +} + +export function buildNavigationBundle(source = navigationSource()): VisorArtifactBundle { + return createVisorArtifactBundleFromGraphql(source, { + fixtureId: FIXTURE_ID, + sourceName: FIXTURE_PATH, + tokenColors: NAV_TOKEN_COLORS, + }); +} + +export function expectedSemanticBundleHash(bundle: VisorArtifactBundle): string { + return hashUiSceneValue({ + ...bundle, + fixture: { + ...bundle.fixture, + sourceHash: undefined, + }, + source: { + ...bundle.source, + text: undefined, + }, + hashes: { + ...bundle.hashes, + sourceHash: undefined, + bundleHash: undefined, + }, + }); +}