From 694ea5fdd0ff6f0e34adc9f5b68a10de161c025b Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 09:39:19 -0300 Subject: [PATCH 1/6] fix(deploy): consume nearby universe from R2 --- .github/workflows/nightly.yml | 10 ++++--- package.json | 2 +- site/application-world-context.mts | 3 ++- tools/body-additions.test.mts | 23 +++++++++++++++- tools/check-ci.test.mts | 7 ++--- tools/check-deploy-assets.mts | 2 +- tools/check-deploy-assets.test.mts | 2 +- tools/prepare-catalog.mts | 43 +++++++++++++++++++++++++++--- 8 files changed, 76 insertions(+), 16 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a5029fb8ec..f2c2a19144 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -123,18 +123,20 @@ jobs: env: CSSEARTH_ALLOW_MISSING_ASSETS: "1" run: pnpm setup:assets - - name: Build the site with ASSET_ORIGIN set to a test origin + - name: Build the site with the production asset origin and reject metadata drift env: NODE_OPTIONS: --max-old-space-size=6144 CSSEARTH_ALLOW_MISSING_ASSETS: "1" - ASSET_ORIGIN: https://asset-origin-ci-check.example - run: pnpm build:deploy + ASSET_ORIGIN: https://earth-assets.lowpoly.cc + run: | + pnpm build:deploy + pnpm check:deploy-assets - name: Check no page references a same-origin /scenes/ address run: node tools/check-asset-origin-scenes.mts dist - name: Check production navigation loads content-addressed assets env: CSSEARTH_TEST_ORIGIN: http://127.0.0.1:4212 - CSSEARTH_ASSET_ORIGIN: https://asset-origin-ci-check.example + CSSEARTH_ASSET_ORIGIN: https://earth-assets.lowpoly.cc PLAYWRIGHT_CHANNEL: chromium run: | pnpm exec playwright install --with-deps chromium diff --git a/package.json b/package.json index cb0a157f21..0893213d4a 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "predev": "pnpm build:tools && pnpm prepare:object-json && pnpm prepare:environment-images && pnpm prepare:minimap && pnpm prepare:galaxy-field", "prepare:catalog": "node tools/prepare-catalog.mts", "prepare:checkout": "node tools/restore-source-inputs.mts && pnpm prepare:planets", - "prepare:deploy": "pnpm build:tools && pnpm setup:assets && pnpm prepare:object-json && pnpm prepare:environment-images && node tools/nebula/prepare.mts --if-missing && pnpm prepare:minimap && pnpm prepare:galaxy-field:data && pnpm prepare:deploy-catalogues", + "prepare:deploy": "pnpm build:tools && pnpm setup:assets && pnpm prepare:object-json && pnpm prepare:environment-images && node tools/nebula/prepare.mts --if-missing && pnpm prepare:minimap && pnpm prepare:deploy-catalogues", "prepare:deploy-catalogues": "node tools/prepare-facilities.mts --catalog-only", "prepare:earth-global": "node tools/objects/geographic-pages/operations/prepare-global-wmts.mts --object=earth && node tools/objects/geographic-pages/operations/integrate-global-wmts.mts --object=earth --latest", "prepare:environment-images": "node tools/objects/dist/restore-environment-images.js", diff --git a/site/application-world-context.mts b/site/application-world-context.mts index 599b39aaf5..62a09875a8 100644 --- a/site/application-world-context.mts +++ b/site/application-world-context.mts @@ -65,6 +65,7 @@ function loadApplicationUniverse(): Promise { } } }; }; const volumeSet = resourceSet(applicationContext.volume.objectId), starSet = resourceSet(applicationContext.stars.objectId); + const backgroundPointSet = resourceSet('nearby-universe'); const [volume, pointAppearance] = await Promise.all([ loadPreparedCssVolume(volumeSet.descriptor, volumeSet.transport), loadPreparedPointAppearance(starSet.descriptor, starSet.transport), @@ -109,7 +110,7 @@ function loadApplicationUniverse(): Promise { const plannerSource = { contextUrl: APPLICATION_WORLD_CONTEXT_URL }; const catalogBank = { fadeStartDistanceM: galaxyPresentation.fadeStartDistanceM, fullDistanceM: galaxyPresentation.fullDistanceM, clusters: { fadeStartDistanceM: clusterPresentation.fadeStartDistanceM, fullDistanceM: clusterPresentation.fullDistanceM } }; - const universe = createPreparedUniverse({ environmentLinks: { 'milky-way': '/sun/?overview=milky-way' }, context: applicationContext, volume, pointAppearance, sprites, imageLayerBanks, loadImageLayer, volumeLensBanks, loadVolumeLens, backgroundPointSha256: parseObjectDescriptor(galaxyFieldDescriptor).prepared?.sha256, backgroundPointManifest: new URL('../src/objects/nearby-universe/prepared/points.json', import.meta.url).href, backgroundPointCloud: new URL('../src/objects/nearby-universe/prepared/cloud.webp', import.meta.url).href, annotationPriorities, annotationOpacities, plannerSource, catalogBank, + const universe = createPreparedUniverse({ environmentLinks: { 'milky-way': '/sun/?overview=milky-way' }, context: applicationContext, volume, pointAppearance, sprites, imageLayerBanks, loadImageLayer, volumeLensBanks, loadVolumeLens, backgroundPointSha256: parseObjectDescriptor(galaxyFieldDescriptor).prepared?.sha256, backgroundPointManifest: backgroundPointSet.resolve('prepared/points.json'), backgroundPointCloud: backgroundPointSet.resolve('prepared/cloud.webp'), annotationPriorities, annotationOpacities, plannerSource, catalogBank, loadCatalog: async () => { const { galaxies, clusters, nebulae } = await loadCatalogs(); return { payload: galaxies, galaxySample: galaxyDisplaySample, nebulae, ...catalogBank, diff --git a/tools/body-additions.test.mts b/tools/body-additions.test.mts index 0b311c2e26..93fb762f86 100644 --- a/tools/body-additions.test.mts +++ b/tools/body-additions.test.mts @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile, type FileHandle } from 'node:f import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import test from 'node:test'; -import { contextObjectModule, readCatalog, prepareCatalog } from './prepare-catalog.mts'; +import { contextObjectAssetUrls, contextObjectModule, readCatalog, prepareCatalog } from './prepare-catalog.mts'; import { prepareBodyRecords } from '../packages/astronomy/tools/body-records.mts'; import { literalRecords } from '../packages/astronomy/tools/lib/write-record-sections.mts'; import type { PathLike } from 'node:fs'; @@ -25,6 +25,27 @@ test('context prepared resources stay external until their bank is selected', () 'The source-only point bank stays excluded while its published resources remain external.'); }); +test('asset-origin context resources come from inventories without local prepared bytes', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-context-assets-')); + t.after(() => rm(root, { recursive: true, force: true })); + await write(resolve(root, 'src/objects/nearby-universe/runtime-assets.json'), { + schema: 'cssnearby-universe-runtime-assets@1', resourceRoot: 'prepared', assets: [ + { filename: 'points.json', sha256: 'a'.repeat(64), bytes: 10 }, + { filename: 'cloud.webp', sha256: 'b'.repeat(64), bytes: 20 }, + { filename: 'datasets/preview.webp', sha256: 'c'.repeat(64), bytes: 30, location: 'public' }, + ], + }); + const origin = 'https://assets.example.test'; + const assets = await contextObjectAssetUrls([{ id: 'nearby-universe' }], root, origin); + assert.deepEqual(assets, { + '../src/objects/nearby-universe/prepared/cloud.webp': `${origin}/runtime-assets/${'b'.repeat(64)}/cloud.webp`, + '../src/objects/nearby-universe/prepared/points.json': `${origin}/runtime-assets/${'a'.repeat(64)}/points.json`, + }); + const source = contextObjectModule([{ id: 'nearby-universe', type: 'galaxy-point-field' }], assets); + assert.match(source, /https:\/\/assets\.example\.test\/runtime-assets/u); + assert.doesNotMatch(source, /query: '\?url&no-inline'/u); +}); + async function addBody(root: string, id: string, classification: string, parent = 'sun') { await write(resolve(root, `src/objects/${id}/object.json`), { schema: 'cssearth-object@1', id, type: 'test', properties: { diff --git a/tools/check-ci.test.mts b/tools/check-ci.test.mts index 0a41038c7f..52197a4aec 100644 --- a/tools/check-ci.test.mts +++ b/tools/check-ci.test.mts @@ -34,7 +34,7 @@ test('the deploy consumes installed assets, rebuilds only catalogues and rejects assert.match(workflow,/git diff --quiet -- src\/objects/); assert.doesNotMatch(workflow,/ASSET_ORIGIN=https:\/\/earth-assets\.lowpoly\.cc pnpm build(?:\s|$)/); assert.match(packageFile.scripts['prepare:deploy']??'',/node tools\/nebula\/prepare\.mts --if-missing/); - assert.match(packageFile.scripts['prepare:deploy']??'',/pnpm prepare:galaxy-field:data/); + assert.doesNotMatch(packageFile.scripts['prepare:deploy']??'',/pnpm prepare:galaxy-field:data/); assert.match(packageFile.scripts['prepare:deploy']??'',/pnpm prepare:deploy-catalogues/); assert.equal(packageFile.scripts['prepare:deploy-catalogues'],'node tools/prepare-facilities.mts --catalog-only'); assert.match(packageFile.scripts['setup:assets']??'',/node tools\/setup-volume-metadata\.mts/); @@ -43,8 +43,9 @@ test('the deploy consumes installed assets, rebuilds only catalogues and rejects test('the PR asset-origin check exercises the exact deploy build path',async()=>{ const workflow=await readFile(new URL('../.github/workflows/nightly.yml',import.meta.url),'utf8'); const steps=readCiSteps(workflow,'asset-origin-build'); - const build=steps.find(step=>step.name==='Build the site with ASSET_ORIGIN set to a test origin'); - assert.equal(build?.run.trim(),'pnpm build:deploy'); + const build=steps.find(step=>step.name==='Build the site with the production asset origin and reject metadata drift'); + assert.equal(build?.env.ASSET_ORIGIN,'https://earth-assets.lowpoly.cc'); + assert.equal(build?.run.trim(),'pnpm build:deploy\npnpm check:deploy-assets'); }); test('--quick skips only the network and documentation steps, and refuses a job without them',async()=>{ const lint=readCiSteps(await readFile(new URL('../.github/workflows/universe.yml',import.meta.url),'utf8'),'lint'); diff --git a/tools/check-deploy-assets.mts b/tools/check-deploy-assets.mts index 66f5b041c5..8744353a4e 100644 --- a/tools/check-deploy-assets.mts +++ b/tools/check-deploy-assets.mts @@ -8,7 +8,7 @@ import { inventoriedAssets, inventoriedObjectIds, RUNTIME_ASSET_ORIGIN } from '. const execFileAsync = promisify(execFile); const textExtensions = new Set(['.css', '.html', '.js', '.json', '.map', '.svg', '.txt', '.xml']); const escapedOrigin = RUNTIME_ASSET_ORIGIN.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); -const runtimeAssetPattern = new RegExp(`${escapedOrigin}/runtime-assets/[a-f0-9]{64}/[a-zA-Z0-9._/-]+`, 'gu'); +const runtimeAssetPattern = new RegExp(`${escapedOrigin}/runtime-assets/[a-f0-9]{64}/[a-zA-Z0-9._@/-]+`, 'gu'); export function runtimeAssetUrls(text: string): string[] { return [...new Set(text.match(runtimeAssetPattern) ?? [])].sort(); diff --git a/tools/check-deploy-assets.test.mts b/tools/check-deploy-assets.test.mts index 40b949ad3a..5329e5ae15 100644 --- a/tools/check-deploy-assets.test.mts +++ b/tools/check-deploy-assets.test.mts @@ -3,7 +3,7 @@ import test from 'node:test'; import { runtimeAssetUrls, unknownRuntimeAssetUrls } from './check-deploy-assets.mts'; const hash = 'a'.repeat(64); -const known = `https://earth-assets.lowpoly.cc/runtime-assets/${hash}/datasets/preview.webp`; +const known = `https://earth-assets.lowpoly.cc/runtime-assets/${hash}/datasets/preview@2x.webp`; test('deploy asset closure extracts unique R2 runtime URLs and rejects an uninventoried hash', () => { assert.deepEqual(runtimeAssetUrls(``), [known]); diff --git a/tools/prepare-catalog.mts b/tools/prepare-catalog.mts index b6217767d6..8aeeeff128 100644 --- a/tools/prepare-catalog.mts +++ b/tools/prepare-catalog.mts @@ -1,5 +1,6 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import { defineObjects } from '../site/object-schema.mts'; import { catalogEntry } from '../site/object-catalog.mts'; @@ -9,6 +10,8 @@ import { prepareSceneDistance, readPreparedFocusObjects } from './prepare-naviga import { prepareObjectDiscovery } from './prepare-object-discovery.mts'; import { BODIES } from '@cssearth/astronomy'; +import { assetOrigin } from '../site/asset-origin.mts'; +import { requirePreparedAssetManifest, requireRuntimeAssetManifest } from '../src/platform/runtime-asset-closure.mts'; const root = resolve(import.meta.dirname, '..'); const byOrder = (a: CatalogEntry, b: CatalogEntry) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id, 'en'); @@ -46,12 +49,15 @@ export async function readContextObjects(objectsDirectory = resolve(root, 'src/o } /** Every object shares one folder, so the application globs only the context objects it loads, each by name. */ -export function contextObjectModule(contexts: readonly { id: string; type: string }[]) { +export function contextObjectModule(contexts: readonly { id: string; type: string }[], remoteAssets: Readonly> = {}) { const list = (patterns: readonly string[]) => `[${patterns.map(pattern => `'${pattern}'`).join(', ')}]`; const folder = (id: string) => `../src/objects/${id}`; // A point field's binary star bank is a preparation input, never an application asset. const assets = [...contexts.map(({ id }) => `${folder(id)}/prepared/**/*.{json,png,webp,bin}`), ...contexts.filter(({ type }) => type === 'point-field').map(({ id }) => `!${folder(id)}/prepared/*.bin`)]; + const assetExpression = Object.keys(remoteAssets).length + ? JSON.stringify(remoteAssets) + : `import.meta.glob(${list(assets)}, { query: '?url&no-inline', import: 'default', eager: true })`; return '// Generated by tools/prepare-catalog.mts. Context objects are registered descriptors without a catalog entry.\n' + `export const CONTEXT_OBJECT_DESCRIPTORS: Record = import.meta.glob(${list(contexts.map(({ id }) => `${folder(id)}/object.json`))}, { eager: true, import: 'default' });\n` + // These URLs are serialized into every object's prepared-focus shell. Vite's @@ -59,10 +65,37 @@ export function contextObjectModule(contexts: readonly { id: string; type: strin // otherwise tiny resolver map into a megabyte of eager HTML and JavaScript. // Keep every prepared resource as an independently cached, demand-loaded // asset; the existing loaders still decide which bank is fetched. - `export const CONTEXT_OBJECT_ASSET_URLS: Record = import.meta.glob(${list(assets)}, { query: '?url&no-inline', import: 'default', eager: true });\n` + + `export const CONTEXT_OBJECT_ASSET_URLS: Record = ${assetExpression};\n` + `export const CONTEXT_OBJECT_PREPARED_JSON: Record = import.meta.glob(${list(contexts.map(({ id }) => `${folder(id)}/prepared/*.json`))}, { eager: true, import: 'default' });\n`; } +/** An asset-origin build resolves context resources exclusively from committed inventories. It neither needs nor + * discovers the prepared payload on disk, so a deploy cannot silently regenerate a second package identity. */ +export async function contextObjectAssetUrls(contexts: readonly { id: string }[], projectRoot: string, origin: string | null) { + if (!origin) return {}; + const siteRoot = resolve(projectRoot, 'site'), entries: (readonly [string, string])[] = []; + for (const { id } of contexts) { + const directory = resolve(projectRoot, 'src/objects', id); + const runtimePath = resolve(directory, 'runtime-assets.json'); + if (existsSync(runtimePath)) { + const manifest = requireRuntimeAssetManifest(id, JSON.parse(await readFile(runtimePath, 'utf8'))); + if (manifest.resourceRoot === 'prepared') for (const asset of manifest.assets.filter(asset => asset.location !== 'public')) { + entries.push([relative(siteRoot, resolve(directory, 'prepared', asset.filename)).split(sep).join('/'), + `${origin}/runtime-assets/${asset.sha256}/${asset.filename}`]); + } + } + const preparedPath = resolve(directory, 'prepared-assets.json'); + if (existsSync(preparedPath)) { + const manifest = requirePreparedAssetManifest(id, JSON.parse(await readFile(preparedPath, 'utf8'))); + for (const asset of manifest.assets) entries.push([ + relative(siteRoot, resolve(directory, 'prepared', asset.filename)).split(sep).join('/'), + `${origin}/runtime-assets/${asset.sha256}/${asset.filename}`, + ]); + } + } + return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right, 'en'))); +} + async function writeGenerated(output: string, text: string) { await mkdir(dirname(output), { recursive: true }); try { if (await readFile(output, 'utf8') === text) return; } @@ -95,7 +128,9 @@ export async function prepareCatalog({ projectRoot = root } = {}) { await writeGenerated(resolve(projectRoot, 'site/prepared-object-discovery.json'), JSON.stringify(Object.fromEntries(discoveries)) + '\n'); await writeGenerated(resolve(projectRoot, 'site/prepared-object-distances.json'), JSON.stringify(Object.fromEntries(entries.map(entry => [entry.id, entry.distance]))) + '\n'); await writeGenerated(resolve(projectRoot, 'site/prepared-focus-objects.json'), JSON.stringify(focuses) + '\n'); - await writeGenerated(resolve(projectRoot, 'site/prepared-context-objects.mts'), contextObjectModule(await readContextObjects(resolve(projectRoot, 'src/objects')))); + const contexts = await readContextObjects(resolve(projectRoot, 'src/objects')); + await writeGenerated(resolve(projectRoot, 'site/prepared-context-objects.mts'), contextObjectModule(contexts, + await contextObjectAssetUrls(contexts, projectRoot, assetOrigin()))); return entries; } From 58b9f73c72d583386ede319db616fa7669922a89 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 10:30:52 -0300 Subject: [PATCH 2/6] fix(deploy): keep world context generation read-only --- package.json | 1 + tools/objects/prepare-spatial-context.test.ts | 14 +++++++- tools/objects/prepare-spatial-context.ts | 32 +++++++++++++++---- tools/prepare-object.mts | 2 +- tools/prepare-object.test.mts | 12 +++++++ 5 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 tools/prepare-object.test.mts diff --git a/package.json b/package.json index 0893213d4a..c1fd369bdd 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "prepare:volume": "node tools/objects/dist/prepare-volume.js", "prepare:wordmark": "node tools/prepare-wordmark-rail.mts", "prepare:world-context": "node tools/objects/dist/prepare-spatial-context.js src/objects/sun/source/navigation/universe.json src/objects/sun/prepared/world-context.json && pnpm prepare:moon-labels", + "prepare:world-context:author": "node tools/objects/dist/prepare-spatial-context.js src/objects/sun/source/navigation/universe.json src/objects/sun/prepared/world-context.json --pin-references && pnpm prepare:moon-labels", "pretest": "pnpm prepare:shell", "preview": "node tools/preview.mts", "preview:native-scroll": "node tools/experiments/native-scroll/run.mts", diff --git a/tools/objects/prepare-spatial-context.test.ts b/tools/objects/prepare-spatial-context.test.ts index f838801382..5a12708135 100644 --- a/tools/objects/prepare-spatial-context.test.ts +++ b/tools/objects/prepare-spatial-context.test.ts @@ -8,7 +8,7 @@ import { SCENE_SATELLITE_IDS, SMALL_BODY_IDS, asteroidPositionKm, COMET_IDS, com systemBarycentreHeliocentricAu, M_PER_AU, STAR_IDS, starStateKm, HOSTED_PLANET_IDS, hostedPlanetStateRelativeKm } from '@cssearth/astronomy'; import type { SmallBodyId, CometId, BodyId, DwarfPlanetId, Vsop87BodyKey, StarId, HostedPlanetId } from '@cssearth/astronomy'; import { readCatalog } from '../prepare-catalog.mts'; -import { prepareSpatialContext } from './prepare-spatial-context.js'; +import { parseSpatialContextCommand, prepareSpatialContext } from './prepare-spatial-context.js'; const root = process.cwd(); const sourcePath = resolve(root, 'src/objects/sun/source/navigation/universe.json'); @@ -16,6 +16,18 @@ const solarGeometryPath = resolve(root, 'src/platform/solar-geometry.mts'); const contextEntries = (await readCatalog()).filter(body => body.context && body.id !== 'sun') .sort((a, b) => (a.context!.order ?? Number.MAX_SAFE_INTEGER) - (b.context!.order ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id, 'en')); +test('deploy generation never repins source manifests unless authoring requests it', () => { + const generated = parseSpatialContextCommand(['source.json', 'prepared.json'], '/repo'); + assert.equal(generated.pinReferences, false); + assert.equal(generated.sourcePath, '/repo/source.json'); + assert.equal(generated.outputPath, '/repo/prepared.json'); + assert.equal(generated.solarGeometryPath, '/repo/src/platform/solar-geometry.mts'); + + const authored = parseSpatialContextCommand(['source.json', 'prepared.json', '--pin-references'], '/repo'); + assert.equal(authored.pinReferences, true); + assert.throws(() => parseSpatialContextCommand(['source.json', 'prepared.json', '--unknown'], '/repo'), /Usage:/); +}); + test('migrated world-frame radii override astronomy only at the same position and epoch', async () => { const directory = await mkdtemp(resolve(tmpdir(), 'cssearth-spatial-context-')); diff --git a/tools/objects/prepare-spatial-context.ts b/tools/objects/prepare-spatial-context.ts index baf1896f8d..b7f02575ac 100644 --- a/tools/objects/prepare-spatial-context.ts +++ b/tools/objects/prepare-spatial-context.ts @@ -26,6 +26,25 @@ export interface SpatialContextPreparationOptions { readonly objectsDirectory?: string; } +export interface SpatialContextCommandOptions extends SpatialContextPreparationOptions { + /** Authoring-only: update source manifests that deliberately pin the generated context. */ + readonly pinReferences: boolean; +} + +/** Parse the CLI without making deploy/build generation an implicit authoring operation. */ +export function parseSpatialContextCommand(args: readonly string[], cwd = process.cwd()): SpatialContextCommandOptions { + const pinReferences = args.includes('--pin-references'); + if (args.filter(argument => argument === '--pin-references').length > 1 || args.some(argument => argument.startsWith('--') && argument !== '--pin-references')) { + throw new TypeError('Usage: prepare-spatial-context [solar-geometry.mts] [--pin-references]'); + } + const [sourcePath, outputPath, solarGeometryPath = resolve(cwd, 'src/platform/solar-geometry.mts'), ...extra] = + args.filter(argument => argument !== '--pin-references'); + if (!sourcePath || !outputPath || extra.length > 0) { + throw new TypeError('Usage: prepare-spatial-context [solar-geometry.mts] [--pin-references]'); + } + return { sourcePath: resolve(cwd, sourcePath), outputPath: resolve(cwd, outputPath), solarGeometryPath: resolve(cwd, solarGeometryPath), pinReferences }; +} + /** Prepares a renderer-neutral solar context from a pinned source document and epoch geometry adapter. */ export async function prepareSpatialContext(options: SpatialContextPreparationOptions): Promise { const input = JSON.parse(await readFile(options.sourcePath, 'utf8')); @@ -206,10 +225,11 @@ function eccentricity(value: unknown, id: string): number { const result = numbe const invoked = process.argv[1] && basename(fileURLToPath(import.meta.url)) === 'prepare-spatial-context.js' && resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (invoked) { - const [sourcePath, outputPath, solarGeometryPath = resolve(process.cwd(), 'src/platform/solar-geometry.mts')] = process.argv.slice(2); - if (!sourcePath || !outputPath || process.argv.length > 5) throw new TypeError('Usage: prepare-spatial-context [solar-geometry.mts]'); - await prepareSpatialContext({ sourcePath: resolve(sourcePath), outputPath: resolve(outputPath), solarGeometryPath }); - // Manifests that pin this output (the nearby universe's navigation frame) follow it, so no star addition leaves a stale pin. - const { pinManifestsReferencing } = await import(pathToFileURL(resolve(process.cwd(), 'tools/pin-object-documents.mts')).href) as { pinManifestsReferencing: (root: string, path: string) => Promise<{ objectId: string; path: string; expectedBytes: number }[]> }; - for (const change of await pinManifestsReferencing(process.cwd(), relative(process.cwd(), resolve(outputPath)))) console.log(`pinned ${change.objectId} ${change.path} (${change.expectedBytes} bytes)`); + const { pinReferences, ...options } = parseSpatialContextCommand(process.argv.slice(2)); + await prepareSpatialContext(options); + if (pinReferences) { + // Explicit object authoring keeps manifests that pin this output in step with it. + const { pinManifestsReferencing } = await import(pathToFileURL(resolve(process.cwd(), 'tools/pin-object-documents.mts')).href) as { pinManifestsReferencing: (root: string, path: string) => Promise<{ objectId: string; path: string; expectedBytes: number }[]> }; + for (const change of await pinManifestsReferencing(process.cwd(), relative(process.cwd(), options.outputPath))) console.log(`pinned ${change.objectId} ${change.path} (${change.expectedBytes} bytes)`); + } } diff --git a/tools/prepare-object.mts b/tools/prepare-object.mts index d833925f0b..0b41bc790c 100644 --- a/tools/prepare-object.mts +++ b/tools/prepare-object.mts @@ -33,7 +33,7 @@ export const PREPARATION_STEPS: readonly PreparationStep[] = Object.freeze [node('tools/prepare-object-json.mts', id)] }, { name: 'text', purpose: 'prepare the reader text within its budgets', commands: async id => [node('tools/prepare-text.mts', id)] }, { name: 'markers', purpose: 'draw the navigation markers', commands: async id => [node('tools/prepare-navigation.mts', id)] }, - { name: 'world', purpose: 'place the object in the world context; manifests that pin it follow', commands: async () => [['pnpm', 'prepare:world-context']] }, + { name: 'world', purpose: 'place the object in the world context; manifests that pin it follow', commands: async () => [['pnpm', 'prepare:world-context:author']] }, { name: 'provenance', purpose: 'record provenance for this object and rebuild the shared sources catalogue', commands: async id => [node('tools/prepare-provenance.mts', id)] }, ]); diff --git a/tools/prepare-object.test.mts b/tools/prepare-object.test.mts new file mode 100644 index 0000000000..4a3b59dbb4 --- /dev/null +++ b/tools/prepare-object.test.mts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import packageJson from '../package.json' with { type: 'json' }; +import { PREPARATION_STEPS } from './prepare-object.mts'; + +test('deploy generation and object authoring use distinct world-context commands', async () => { + assert.doesNotMatch(packageJson.scripts['prepare:world-context'], /--pin-references/u); + assert.match(packageJson.scripts['prepare:world-context:author'], /--pin-references/u); + const world = PREPARATION_STEPS.find(step => step.name === 'world'); + assert.ok(world); + assert.deepEqual(await world.commands('sun'), [['pnpm', 'prepare:world-context:author']]); +}); From 4932a4f9539ad6ad485884bf06114b66c07f2ffd Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 10:38:33 -0300 Subject: [PATCH 3/6] fix(deploy): read prepared context provenance --- tools/context-provenance.test.mts | 10 ++++++ tools/prepare-facilities.mts | 3 +- tools/read-prepared-context-provenance.mts | 36 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tools/read-prepared-context-provenance.mts diff --git a/tools/context-provenance.test.mts b/tools/context-provenance.test.mts index 92b33858fc..4a493c0d77 100644 --- a/tools/context-provenance.test.mts +++ b/tools/context-provenance.test.mts @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile, readdir, mkdir, mkdtemp, rm, cp, copyFile, symlink } from 'node:fs/promises'; import { prepareContextProvenance } from './prepare-context-provenance.mts'; +import { readPreparedContextProvenance } from './read-prepared-context-provenance.mts'; import { prepareVolumeProvenance } from './prepare-volume-provenance.mts'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -28,6 +29,15 @@ test('context provenance binds every declared output and installs one complete i assert.ok(context.provenance.sources.some(s=>s.sourceBinding?.kind==='catalogued')); } }); + +test('deploy catalogue recovery reads prepared contexts without authoring intermediates', async () => { + const contexts = await readPreparedContextProvenance({ input: path => { + assert.doesNotMatch(path, /sun\/prepared\/world-context\.json/u); + return readFile(path); + } }); + assert.deepEqual(contexts.map(context => context.id), ['galaxy-clusters', 'local-group', 'nearby-universe']); + assert.ok(contexts.every(context => context.outputs.length === 0)); +}); test('changed prepared bytes and authored source documents are rejected', async () => { for(const target of ['src/objects/local-group/prepared/manifest.json','src/objects/nearby-universe/source/preparation/field.json']) { await assert.rejects(prepareContextProvenance({input:async path=>{ diff --git a/tools/prepare-facilities.mts b/tools/prepare-facilities.mts index 377ee2095a..0d0150d8dd 100644 --- a/tools/prepare-facilities.mts +++ b/tools/prepare-facilities.mts @@ -1,5 +1,6 @@ import { sha256 } from '../src/platform/sha256.mts'; import { prepareContextProvenance, contextProvenanceCompilerClosure } from './prepare-context-provenance.mts'; +import { readPreparedContextProvenance } from './read-prepared-context-provenance.mts'; import { spatialSourceCitations } from './spatial-source-citations.mts'; import { sourceResolver, parseSourceBinding } from '../src/platform/source-catalog.mts'; import { compileSourceUsage } from '../src/platform/source-usage.mts'; @@ -143,7 +144,7 @@ export async function prepareFacilities({ root = resolve(import.meta.dirname, '. // and previews from their sources, but catalog-only publication must never invent a second package identity. const volumes = publish === 'catalogues' ? [...await readPreparedVolumeProvenance({ root, input }), - ...((await prepareContextProvenance({ root, input })).map(context => ({ ...context, outputs: [] })))] + ...await readPreparedContextProvenance({ root, input })] : [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })]; for (const volume of volumes) { const document = validateObjectProvenance(volume.provenance, volume.id); diff --git a/tools/read-prepared-context-provenance.mts b/tools/read-prepared-context-provenance.mts new file mode 100644 index 0000000000..ef96dcc677 --- /dev/null +++ b/tools/read-prepared-context-provenance.mts @@ -0,0 +1,36 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { sourceArray, sourceObject, sourceText } from '../src/platform/source-catalog.mts'; +import { validateObjectProvenance } from '../src/platform/object-provenance.mts'; +import type { ProvenanceDocument } from '../src/platform/object-provenance.mts'; + +export interface PreparedContextProvenance { + id: string; name: string; route: string; base: string; controls: readonly never[]; provenance: ProvenanceDocument; + outputs: readonly { path: string; text: string | Uint8Array }[]; +} + +/** Read the prepared context packages installed by setup:assets. Deploy catalogue compilation must not replay + * authoring provenance against a platform-specific generated intermediate. */ +export async function readPreparedContextProvenance({ root = process.cwd(), input = path => readFile(resolve(root, path)) }: { + root?: string; input?: (path: string) => Promise; +} = {}): Promise { + const results: PreparedContextProvenance[] = []; + for (const folder of (await readdir(resolve(root, 'src/objects'), { withFileTypes: true })).sort((a,b)=>a.name.localeCompare(b.name))) { + if (!folder.isDirectory()) continue; + const id = folder.name, base = `src/objects/${id}`, sourcePresentationPath = `${base}/source/presentation.json`; + const sourcePresentationBytes = await readFile(resolve(root, sourcePresentationPath)).catch((error: unknown) => { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null; + throw error; + }); + if (sourcePresentationBytes === null || !sourceObject(JSON.parse(sourcePresentationBytes.toString())).provenance) continue; + await input(sourcePresentationPath); + const provenance = validateObjectProvenance(JSON.parse((await input(`${base}/prepared/provenance.json`)).toString()), id); + const presentation = sourceObject(JSON.parse((await input(`${base}/prepared/presentation.json`)).toString())); + const products = sourceArray(presentation.products, sourceObject); + if (products.length !== provenance.products.length || products.some((product, index) => product.id !== provenance.products[index]?.id)) { + throw new Error(`Prepared context presentation differs from provenance: ${id}`); + } + results.push({ id, name: sourceText(presentation.name), route: '/sun/', base, controls: [], provenance, outputs: [] }); + } + return results; +} From d97ae6598d1b3d108cac8d1a57a5dae5d0667a2d Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 10:44:11 -0300 Subject: [PATCH 4/6] fix(deploy): restore context catalogue metadata --- tools/setup-volume-metadata.mts | 14 ++++++++------ tools/setup-volume-metadata.test.mts | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tools/setup-volume-metadata.mts b/tools/setup-volume-metadata.mts index cbd3d670c7..e23f55e83f 100644 --- a/tools/setup-volume-metadata.mts +++ b/tools/setup-volume-metadata.mts @@ -9,8 +9,8 @@ import { hasErrorCode } from './source-values.mts'; export const VOLUME_METADATA_FILENAMES = ['presentation.json', 'provenance.json'] as const; -/** Select only the small R2-backed package metadata needed to compile deploy catalogues. Volume textures and - * slices stay remote; restoring the full runtime-assets closure here can be tens of gigabytes. */ +/** Select only the small R2-backed package metadata needed to compile deploy catalogues. Volume and context + * payloads stay remote; restoring the full runtime-assets closure here can be tens of gigabytes. */ export async function preparedVolumeMetadataAssets(root = resolve(import.meta.dirname, '..')) { const ids: string[] = []; const folders = await readdir(resolve(root, 'src/objects'), { withFileTypes: true }); @@ -22,8 +22,10 @@ export async function preparedVolumeMetadataAssets(root = resolve(import.meta.di }); if (!bytes) continue; const presentation = sourceObject(JSON.parse(bytes.toString('utf8'))); - if (presentation.schema !== 'cssearth-volume-presentation-source@1') continue; - if (sourceText(presentation.objectId) !== folder.name) throw new TypeError(`Mismatched volume presentation object: ${folder.name}.`); + const volume = presentation.schema === 'cssearth-volume-presentation-source@1'; + const context = presentation.provenance !== undefined && existsSync(resolve(root, 'src/objects', folder.name, 'runtime-assets.json')); + if (!volume && !context) continue; + if (volume && sourceText(presentation.objectId) !== folder.name) throw new TypeError(`Mismatched volume presentation object: ${folder.name}.`); ids.push(folder.name); } @@ -36,7 +38,7 @@ export async function preparedVolumeMetadataAssets(root = resolve(import.meta.di const matches = assets.filter(asset => asset.id === id && asset.filename === filename); if (matches.length !== 1 || matches[0]!.location === 'public' || matches[0]!.file !== resolve(root, 'src/objects', id, 'prepared', filename)) { - throw new TypeError(`Volume ${id} must inventory exactly one prepared/${filename} runtime asset.`); + throw new TypeError(`Catalogue package ${id} must inventory exactly one prepared/${filename} runtime asset.`); } } return { ids, assets }; @@ -45,7 +47,7 @@ export async function preparedVolumeMetadataAssets(root = resolve(import.meta.di if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { const root = resolve(import.meta.dirname, '..'); const { ids, assets } = await preparedVolumeMetadataAssets(root); - console.log(`Setting up catalogue metadata for ${ids.join(', ')}: ${assets.length} file(s); volume data stays on R2.`); + console.log(`Setting up catalogue metadata for ${ids.join(', ')}: ${assets.length} file(s); bulk data stays on R2.`); const result = await installRuntimeAssets(assets, { onProgress: ({ completed, total }) => { if (completed % 10 === 0 || completed === total) console.log(`Volume metadata: ${completed}/${total}`); } }); diff --git a/tools/setup-volume-metadata.test.mts b/tools/setup-volume-metadata.test.mts index db6c55cdb9..8ef8266aba 100644 --- a/tools/setup-volume-metadata.test.mts +++ b/tools/setup-volume-metadata.test.mts @@ -9,7 +9,7 @@ import { preparedVolumeMetadataAssets } from './setup-volume-metadata.mts'; const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); -test('catalogue bootstrap restores only prepared volume metadata and leaves dataset bytes on R2', async t => { +test('catalogue bootstrap restores prepared volume and context metadata while leaving dataset bytes on R2', async t => { const root = await mkdtemp(resolve(tmpdir(), 'cssearth-volume-metadata-')); t.after(() => rm(root, { recursive: true, force: true })); const base = resolve(root, 'src/objects/m31'); @@ -39,14 +39,23 @@ test('catalogue bootstrap restores only prepared volume metadata and leaves data assets: ['presentation.json', 'provenance.json'].map(filename => ({ filename, bytes: files.get(filename)!.length, sha256: sha256(files.get(filename)!) })), })); + const contextBase = resolve(root, 'src/objects/nearby-universe'); + const contextSource = resolve(contextBase, 'source/presentation.json'); + await mkdir(dirname(contextSource), { recursive: true }); + await writeFile(contextSource, JSON.stringify({ provenance: { products: [] } })); + await writeFile(resolve(contextBase, 'runtime-assets.json'), JSON.stringify({ + schema: 'cssnearby-universe-runtime-assets@1', resourceRoot: 'prepared', + assets: [...files].map(([filename, bytes]) => ({ filename, bytes: bytes.length, sha256: sha256(bytes) })), + })); const sourceOnly = resolve(root, 'src/objects/local-group/source/presentation.json'); await mkdir(dirname(sourceOnly), { recursive: true }); await writeFile(sourceOnly, JSON.stringify({ provenance: { products: [] } })); const selected = await preparedVolumeMetadataAssets(root); - assert.deepEqual(selected.ids, ['helix', 'm31']); + assert.deepEqual(selected.ids, ['helix', 'm31', 'nearby-universe']); assert.deepEqual(selected.assets.map(asset => `${asset.id}/${asset.filename}`).sort(), [ 'helix/presentation.json', 'helix/provenance.json', 'm31/presentation.json', 'm31/provenance.json', + 'nearby-universe/presentation.json', 'nearby-universe/provenance.json', ]); const requested: string[] = []; assert.deepEqual(await installRuntimeAssets(selected.assets, { fetcher: async url => { @@ -54,10 +63,11 @@ test('catalogue bootstrap restores only prepared volume metadata and leaves data assert.ok(asset); requested.push(asset.filename); return new Response(files.get(asset.filename)); - } }), { installed: 4, reused: 0, skipped: 0 }); - assert.deepEqual(requested.sort(), ['presentation.json', 'presentation.json', 'provenance.json', 'provenance.json']); + } }), { installed: 6, reused: 0, skipped: 0 }); + assert.deepEqual(requested.sort(), ['presentation.json', 'presentation.json', 'presentation.json', 'provenance.json', 'provenance.json', 'provenance.json']); assert.equal(await readFile(resolve(base, 'prepared/presentation.json'), 'utf8'), files.get('presentation.json')!.toString()); assert.equal(await readFile(resolve(preparedBase, 'prepared/provenance.json'), 'utf8'), files.get('provenance.json')!.toString()); + assert.equal(await readFile(resolve(contextBase, 'prepared/provenance.json'), 'utf8'), files.get('provenance.json')!.toString()); await assert.rejects(readFile(resolve(base, 'prepared/datasets/large.webp')), { code: 'ENOENT' }); await assert.rejects(readFile(resolve(root, 'public/scenes/helix/datasets/large.webp')), { code: 'ENOENT' }); }); From fcdeb3f9cbb8d2210430ea8f9c76f09fa3bec775 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 10:58:38 -0300 Subject: [PATCH 5/6] fix(universe): pin the authored navigation frame --- .../nearby-universe/runtime-assets.json | 4 ++-- .../nearby-universe/source/manifest.json | 21 +++++++++---------- tools/galaxy-field/prepare-points.mts | 2 +- tools/galaxy-field/recipe.test.mts | 18 ++++++++++++++-- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/objects/nearby-universe/runtime-assets.json b/src/objects/nearby-universe/runtime-assets.json index 74d1f9ffe9..e65fbe53f3 100644 --- a/src/objects/nearby-universe/runtime-assets.json +++ b/src/objects/nearby-universe/runtime-assets.json @@ -14,8 +14,8 @@ }, { "filename": "provenance.json", - "bytes": 10363, - "sha256": "ee12abfd6a352def90493a13ade2c8297f6b75694240690980816e3482b582ea" + "bytes": 10354, + "sha256": "00dcf2448fb75c786fca834abb09d7bbdfc4dbd6cb9d235f456b0c65aad04a55" }, { "filename": "presentation.json", diff --git a/src/objects/nearby-universe/source/manifest.json b/src/objects/nearby-universe/source/manifest.json index 42011dd03d..1e289d46eb 100644 --- a/src/objects/nearby-universe/source/manifest.json +++ b/src/objects/nearby-universe/source/manifest.json @@ -114,22 +114,21 @@ "kind": "local", "reason": "Project-authored processing configuration." } - } - ], - "generatedIntermediates": [ + }, { "id": "navigation-frame", - "kind": "generated-intermediate", - "path": "src/objects/sun/prepared/world-context.json", - "origin": "project-preparation", + "kind": "authored-document", + "path": "src/objects/sun/source/navigation/universe.json", + "origin": "project-authored", "credit": "cssEarth contributors", - "acquisition": "Existing prepared shared world frame; only the frame identity and navigation epoch are read. No solar data supplies galaxy positions.", - "expectedSha256": "4330a638b653f9529d1089b0069353ba8669b7f447c055d7c8b929e1e75b7242", - "expectedBytes": 7536975, + "acquisition": "Authored shared navigation frame; only the frame identity and navigation epoch are read. No solar data supplies galaxy positions.", + "expectedSha256": "02a2fc7561bf3402bdda66f88d21108bc0fca01308eaf864ad108321039b4195", + "expectedBytes": 3396, "sourceBinding": { "kind": "local", - "reason": "Prepared application frame identity, not a galaxy measurement." + "reason": "Authored application frame identity, not a galaxy measurement." } } - ] + ], + "generatedIntermediates": [] } diff --git a/tools/galaxy-field/prepare-points.mts b/tools/galaxy-field/prepare-points.mts index b9821a2396..032346c456 100644 --- a/tools/galaxy-field/prepare-points.mts +++ b/tools/galaxy-field/prepare-points.mts @@ -17,7 +17,7 @@ const recipe = await readFieldRecipe(); const presentation=sourceObject(JSON.parse(await readFile('src/objects/nearby-universe/source/presentation.json','utf8'))); const appearance=Object.fromEntries(['pointExposure','pointReferenceDistanceMpc','cloudExposure','cloudMaximumOpacity'].map(key=>{const value=presentation[key];if(typeof value!=='number'||!Number.isFinite(value)||value<=0)throw new TypeError('Invalid field presentation');return [key,value];})); const catalogue = await loadScientificCatalogue(recipe.maximumDistanceMpc, recipe.minimumDistanceMpc, recipe.hubbleKmSPerMpc); -const world = JSON.parse(await readFile('src/objects/sun/prepared/world-context.json', 'utf8')); +const world = JSON.parse(await readFile('src/objects/sun/source/navigation/universe.json', 'utf8')); if (world.frame?.referenceFrame !== 'sun-icrf' || !Number.isFinite(world.frame.epochJdTt)) throw new TypeError('Invalid application world frame.'); const frame = parseDensityVolumeFrame({ referenceFrame: world.frame.referenceFrame, epochJdTt: world.frame.epochJdTt, originM: [0,0,0], localToReferenceXyzw: [0,0,0,1], metersPerUnit: 3.085677581491367e22, boundsUnits: { min: [-200,-200,-200], max: [200,200,200] } }); // Keep half the budget spread through the field; spend the rest on observed concentrations. diff --git a/tools/galaxy-field/recipe.test.mts b/tools/galaxy-field/recipe.test.mts index 138b0a9652..e219709f77 100644 --- a/tools/galaxy-field/recipe.test.mts +++ b/tools/galaxy-field/recipe.test.mts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import { parseFieldRecipe } from './recipe.mts'; +import { sha256 } from '../../src/platform/sha256.mts'; const input: unknown = JSON.parse(await readFile(new URL('../../src/objects/nearby-universe/source/preparation/field.json', import.meta.url), 'utf8')); const recipe = parseFieldRecipe(input); @@ -22,6 +23,19 @@ test('cloud texture needs at least two pixels to normalize its radial coordinate assert.equal(parseFieldRecipe({ schema: 'cssearth-galaxy-field-recipe@1', ...recipe, texture: { ...recipe.texture, size: 2 } }).texture.size, 2); }); +test('the galaxy field pins the authored frame contract, not rendered world geometry', async () => { + const manifest = JSON.parse(await readFile('src/objects/nearby-universe/source/manifest.json', 'utf8')); + const navigation = manifest.documents.find((entry: { id: string }) => entry.id === 'navigation-frame'); + assert.equal(navigation.path, 'src/objects/sun/source/navigation/universe.json'); + const frame = await readFile(navigation.path); + assert.equal(frame.length, navigation.expectedBytes); + assert.equal(sha256(frame), navigation.expectedSha256); + assert.equal(manifest.generatedIntermediates.length, 0); + const preparer = await readFile(new URL('./prepare-points.mts', import.meta.url), 'utf8'); + assert.match(preparer, /sun\/source\/navigation\/universe\.json/u); + assert.doesNotMatch(preparer, /sun\/prepared\/world-context\.json/u); +}); + test('a changed source manifest pin fails before any field output is replaced', async t => { const root = await mkdtemp(resolve(tmpdir(), 'galaxy-field-pins-')); @@ -48,8 +62,8 @@ test('the field bake replaces stale generated files and restores missing receipt await cp(resolve(base,'source'),resolve(root,base,'source'),{recursive:true}); await mkdir(resolve(root,'.local/galaxy-field'),{recursive:true}); await cp('.local/galaxy-field/sources',resolve(root,'.local/galaxy-field/sources'),{recursive:true}); - await mkdir(resolve(root,'src/objects/sun/prepared'),{recursive:true}); - await cp('src/objects/sun/prepared/world-context.json',resolve(root,'src/objects/sun/prepared/world-context.json')); + await mkdir(resolve(root,'src/objects/sun/source/navigation'),{recursive:true}); + await cp('src/objects/sun/source/navigation/universe.json',resolve(root,'src/objects/sun/source/navigation/universe.json')); await writeFile(resolve(root,base,'prepared/points.json'),'stale generation'); await writeFile(resolve(root,base,'prepared/cloud.webp'),'stale generation'); const result=spawnSync(process.execPath,[fileURLToPath(new URL('./prepare-points.mts',import.meta.url))],{cwd:root,encoding:'utf8'}); From ba742f6666c44c4b4560acc06cb482d3052e48a2 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 11:06:15 -0300 Subject: [PATCH 6/6] test: include authored navigation source in offline fixture --- tools/context-provenance.test.mts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/context-provenance.test.mts b/tools/context-provenance.test.mts index 4a493c0d77..75a4e66a53 100644 --- a/tools/context-provenance.test.mts +++ b/tools/context-provenance.test.mts @@ -96,6 +96,11 @@ test('offline context recovery is independent of installed generated images and await copyFile(`${base}/object.json`,join(root,base,'object.json')); await copyFile(`${base}/prepared/manifest.json`,join(root,base,'prepared/manifest.json')); } + await mkdir(join(root,'src/objects/sun/source/navigation'),{recursive:true}); + await copyFile( + 'src/objects/sun/source/navigation/universe.json', + join(root,'src/objects/sun/source/navigation/universe.json'), + ); const offline=await prepareContextProvenance({root,input:path=>readFile(path.startsWith('tools/')?path:join(root,path))}); assert.deepEqual(offline.map(c=>c.id),['galaxy-clusters','local-group','nearby-universe']); const installed=await prepareContextProvenance();