@volter/project 0.0.0-stage → 0.5.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +18 -3
  4. package/package.json +64 -4
  5. package/schemas/engine-api.json +57 -0
  6. package/schemas/engine-api.md +22 -0
  7. package/schemas/volter-project.schema.json +468 -0
  8. package/schemas/volter-settings.schema.json +149 -0
  9. package/src/adapter/adapter-module.ts +997 -0
  10. package/src/adapter/adapter-surface.ts +34 -0
  11. package/src/adapter/authoring-seam-contract.ts +224 -0
  12. package/src/adapter/authoring.ts +1336 -0
  13. package/src/adapter/binding.ts +445 -0
  14. package/src/adapter/debug-registry-ref.ts +19 -0
  15. package/src/adapter/editor-looks.ts +519 -0
  16. package/src/adapter/finders/finder-result.ts +29 -0
  17. package/src/adapter/finders/registry.ts +116 -0
  18. package/src/adapter/frame-capture.ts +80 -0
  19. package/src/adapter/host-context.ts +114 -0
  20. package/src/adapter/index.ts +209 -0
  21. package/src/adapter/ingest/contract-system-slots.ts +28 -0
  22. package/src/adapter/ingest/game-contract-seams.ts +86 -0
  23. package/src/adapter/ingest/game-contract.ts +506 -0
  24. package/src/adapter/ingest/mount-readiness.ts +140 -0
  25. package/src/adapter/loop-gate-report.ts +53 -0
  26. package/src/adapter/manifest-interpreter.ts +64 -0
  27. package/src/adapter/native-entry-surface.ts +100 -0
  28. package/src/adapter/physics-adapter-2d.ts +30 -0
  29. package/src/adapter/render-audio.ts +20 -0
  30. package/src/adapter/render-memory.ts +34 -0
  31. package/src/adapter/renderer-config.ts +84 -0
  32. package/src/adapter/root-adapter.ts +243 -0
  33. package/src/adapter/root-seam-contract.ts +61 -0
  34. package/src/adapter/root-stacking.ts +67 -0
  35. package/src/adapter/seam-evidence.ts +336 -0
  36. package/src/adapter/system-adapter.ts +865 -0
  37. package/src/adapter/system-seam-contract.ts +138 -0
  38. package/src/adapter/system-slot.ts +301 -0
  39. package/src/adapter/transform.ts +35 -0
  40. package/src/adapter/workspace-arrangement.ts +92 -0
  41. package/src/core/system-phase.ts +52 -0
  42. package/src/inspection.ts +281 -0
  43. package/src/manifest/configuration-kinds.ts +241 -0
  44. package/src/manifest/editor-port.ts +69 -0
  45. package/src/manifest/filename.ts +45 -0
  46. package/src/manifest/kind-modules.ts +58 -0
  47. package/src/manifest/load-file.ts +33 -0
  48. package/src/manifest/load.ts +380 -0
  49. package/src/manifest/locate.ts +55 -0
  50. package/src/manifest/runtime-environment.ts +48 -0
  51. package/src/manifest/schema.ts +690 -0
  52. package/src/settings/keys.ts +152 -0
  53. package/src/settings/schema.ts +151 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Read-only first-contact inspection for a folder the user may want to open
3
+ * or adapt. Detection is deliberately evidence-based and never writes files.
4
+ */
5
+
6
+ export type ProjectWorkspaceState =
7
+ | 'volter-project'
8
+ | 'adaptable-project'
9
+ | 'empty-folder'
10
+ | 'unknown-project';
11
+
12
+ export type SuggestedAdapterSurface = 'three' | 'canvas' | 'dom';
13
+
14
+ export interface ProjectInspection {
15
+ workspaceState: ProjectWorkspaceState;
16
+ detectedTechnologies: string[];
17
+ suggestedSurface: SuggestedAdapterSurface | null;
18
+ entryCandidates: string[];
19
+ /** Evidence that makes automatic one-file metadata adaptation unsafe. */
20
+ adaptationBlockers: string[];
21
+ hasManifest: boolean;
22
+ writesPerformed: false;
23
+ summary: string;
24
+ nextAction: string;
25
+ }
26
+
27
+ export interface ProjectInspectionReader {
28
+ exists(path: string): Promise<boolean>;
29
+ read(path: string): Promise<string>;
30
+ list(path: string): Promise<ProjectInspectionEntry[]>;
31
+ }
32
+
33
+ export interface ProjectInspectionEntry {
34
+ name: string;
35
+ path: string;
36
+ type: 'file' | 'dir';
37
+ }
38
+
39
+ export interface IngestManifestOptions {
40
+ name: string;
41
+ engineVersion: string;
42
+ surface?: SuggestedAdapterSurface | undefined;
43
+ entry?: string | undefined;
44
+ }
45
+
46
+ const IGNORED_DIRECTORIES = new Set([
47
+ '.git',
48
+ '.volter',
49
+ 'build',
50
+ 'coverage',
51
+ 'dist',
52
+ 'node_modules',
53
+ 'out',
54
+ ]);
55
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.ts', '.tsx']);
56
+ const MAX_VISITED_FILES = 500;
57
+ const MAX_DIRECTORY_DEPTH = 4;
58
+
59
+ function extension(path: string): string {
60
+ const dot = path.lastIndexOf('.');
61
+ return dot < 0 ? '' : path.slice(dot).toLowerCase();
62
+ }
63
+
64
+ function dependencyNames(value: unknown): Set<string> {
65
+ if (!value || typeof value !== 'object') return new Set();
66
+ const record = value as Record<string, unknown>;
67
+ const names = new Set<string>();
68
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
69
+ const dependencies = record[field];
70
+ if (!dependencies || typeof dependencies !== 'object') continue;
71
+ for (const name of Object.keys(dependencies as Record<string, unknown>)) names.add(name);
72
+ }
73
+ return names;
74
+ }
75
+
76
+ async function readPackageDependencies(reader: ProjectInspectionReader): Promise<Set<string>> {
77
+ if (!(await reader.exists('package.json'))) return new Set();
78
+ try {
79
+ return dependencyNames(JSON.parse(await reader.read('package.json')));
80
+ } catch {
81
+ return new Set();
82
+ }
83
+ }
84
+
85
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: bounded breadth-first file evidence collection keeps traversal limits and filtering in one place.
86
+ async function collectSourceFiles(reader: ProjectInspectionReader): Promise<string[]> {
87
+ const files: string[] = [];
88
+ const queue: Array<{ path: string; depth: number }> = [{ path: '', depth: 0 }];
89
+ while (queue.length > 0 && files.length < MAX_VISITED_FILES) {
90
+ const current = queue.shift();
91
+ if (!current) break;
92
+ let entries: ProjectInspectionEntry[];
93
+ try {
94
+ entries = await reader.list(current.path);
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.type === 'dir') {
100
+ if (current.depth < MAX_DIRECTORY_DEPTH && !IGNORED_DIRECTORIES.has(entry.name)) {
101
+ queue.push({ path: entry.path, depth: current.depth + 1 });
102
+ }
103
+ } else if (SOURCE_EXTENSIONS.has(extension(entry.path)) || entry.path === 'index.html') {
104
+ files.push(entry.path);
105
+ if (files.length >= MAX_VISITED_FILES) break;
106
+ }
107
+ }
108
+ }
109
+ return files;
110
+ }
111
+
112
+ function candidateEntries(files: string[]): string[] {
113
+ const preferredNames = [
114
+ 'src/volter-entry.tsx',
115
+ 'src/volter-entry.ts',
116
+ 'src/volter-host.tsx',
117
+ 'src/volter-host.ts',
118
+ 'src/adapter.tsx',
119
+ 'src/adapter.ts',
120
+ 'src/main.tsx',
121
+ 'src/main.ts',
122
+ 'src/index.tsx',
123
+ 'src/index.ts',
124
+ 'src/main.jsx',
125
+ 'src/main.js',
126
+ 'src/index.jsx',
127
+ 'src/index.js',
128
+ 'index.html',
129
+ ];
130
+ const available = new Set(files);
131
+ const preferred = preferredNames.filter((name) => available.has(name));
132
+ return [...preferred, ...files.filter((name) => !preferred.includes(name))].slice(0, 8);
133
+ }
134
+
135
+ /** Inspect a folder without mutating it or assuming that it is already VOLTER-shaped. */
136
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one ordered evidence-to-recommendation decision table.
137
+ export async function inspectProject(reader: ProjectInspectionReader): Promise<ProjectInspection> {
138
+ if (await reader.exists('volter.project.json')) {
139
+ return {
140
+ workspaceState: 'volter-project',
141
+ detectedTechnologies: ['Volter Editor'],
142
+ suggestedSurface: null,
143
+ entryCandidates: [],
144
+ adaptationBlockers: [],
145
+ hasManifest: true,
146
+ writesPerformed: false,
147
+ summary: 'This folder is already a Volter Editor project.',
148
+ nextAction: 'Open it in the editor.',
149
+ };
150
+ }
151
+
152
+ // An inaccessible root is not an empty folder. Propagate the read failure
153
+ // so callers can report it honestly instead of offering to scaffold over
154
+ // a location they could not inspect.
155
+ const rootEntries = await reader.list('');
156
+ if (rootEntries.length === 0) {
157
+ return {
158
+ workspaceState: 'empty-folder',
159
+ detectedTechnologies: [],
160
+ suggestedSurface: null,
161
+ entryCandidates: [],
162
+ adaptationBlockers: [],
163
+ hasManifest: false,
164
+ writesPerformed: false,
165
+ summary: 'This folder is empty.',
166
+ nextAction: 'Create a new Volter Editor project here.',
167
+ };
168
+ }
169
+
170
+ const dependencies = await readPackageDependencies(reader);
171
+ const files = await collectSourceFiles(reader);
172
+ const technologies: string[] = [];
173
+ if (dependencies.has('@react-three/fiber')) technologies.push('React Three Fiber');
174
+ if (dependencies.has('three')) technologies.push('three.js');
175
+ if (dependencies.has('pixi.js') || [...dependencies].some((name) => name.startsWith('@pixi/'))) {
176
+ technologies.push('PixiJS');
177
+ }
178
+ if (dependencies.has('react')) technologies.push('React');
179
+ if (dependencies.has('vite')) technologies.push('Vite');
180
+
181
+ const suggestedSurface: SuggestedAdapterSurface | null = technologies.some(
182
+ (name) => name === 'three.js' || name === 'React Three Fiber',
183
+ )
184
+ ? 'three'
185
+ : technologies.includes('PixiJS')
186
+ ? 'canvas'
187
+ : technologies.includes('React')
188
+ ? 'dom'
189
+ : null;
190
+ const entries = candidateEntries(files);
191
+ const adaptationBlockers: string[] = [];
192
+ if (suggestedSurface && entries.length === 0) {
193
+ adaptationBlockers.push('No JavaScript or TypeScript entry candidate was found.');
194
+ }
195
+ if (suggestedSurface === 'dom' && entries[0]) {
196
+ let entrySource = '';
197
+ try {
198
+ entrySource = await reader.read(entries[0]);
199
+ } catch {
200
+ adaptationBlockers.push(`The suggested React entry ${entries[0]} could not be read.`);
201
+ }
202
+ if (/\b(?:ReactDOM\s*\.\s*render|createRoot|hydrateRoot)\s*\(/.test(entrySource)) {
203
+ adaptationBlockers.push(
204
+ `The React app mounts itself in ${entries[0]}; Volter Editor ingest requires a host-shim module that default-exports the mounted component and preserves the app's providers.`,
205
+ );
206
+ } else if (!/\bexport\s+default\b/.test(entrySource)) {
207
+ adaptationBlockers.push(
208
+ `The React entry ${entries[0]} does not default-export a component; create a host-shim module that does before adding Volter Editor metadata.`,
209
+ );
210
+ }
211
+ }
212
+ const adaptable = suggestedSurface !== null && adaptationBlockers.length === 0;
213
+
214
+ return {
215
+ workspaceState: adaptable ? 'adaptable-project' : 'unknown-project',
216
+ detectedTechnologies: technologies,
217
+ suggestedSurface,
218
+ entryCandidates: entries,
219
+ adaptationBlockers,
220
+ hasManifest: false,
221
+ writesPerformed: false,
222
+ summary: adaptable
223
+ ? `This looks like an existing ${technologies.join(' + ') || 'web'} project.`
224
+ : suggestedSurface
225
+ ? `This ${technologies.join(' + ') || 'web'} project needs an adapter step before Volter Editor metadata can be added safely.`
226
+ : 'This folder is not empty, but its game framework could not be identified.',
227
+ nextAction: adaptable
228
+ ? 'Review the detected entry and adapter, then add Volter Editor metadata without rewriting the game source.'
229
+ : (adaptationBlockers[0] ??
230
+ 'Choose the entry point and adapter explicitly before adding Volter Editor metadata.'),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Build the one-file, source-preserving metadata bridge for a detected app.
236
+ * The caller is responsible for obtaining explicit consent before writing it.
237
+ */
238
+ export function createIngestManifest(
239
+ inspection: ProjectInspection,
240
+ options: IngestManifestOptions,
241
+ ): Record<string, unknown> {
242
+ const surface = options.surface ?? inspection.suggestedSurface;
243
+ const entry = options.entry ?? inspection.entryCandidates[0];
244
+ const portableEntry = entry?.replace(/\\/g, '/');
245
+ const entryEscapesProject =
246
+ portableEntry !== undefined &&
247
+ (portableEntry.startsWith('/') ||
248
+ portableEntry.startsWith('//') ||
249
+ /^[A-Za-z]:\//.test(portableEntry) ||
250
+ portableEntry.split('/').includes('..'));
251
+ const explicitUnknownOverride =
252
+ inspection.workspaceState === 'unknown-project' &&
253
+ options.surface !== undefined &&
254
+ options.entry !== undefined;
255
+ if (
256
+ (inspection.workspaceState !== 'adaptable-project' && !explicitUnknownOverride) ||
257
+ !surface ||
258
+ !entry ||
259
+ entryEscapesProject
260
+ ) {
261
+ throw new Error(
262
+ entryEscapesProject
263
+ ? 'Adapter entry must be a project-relative path that stays inside the selected folder.'
264
+ : (inspection.adaptationBlockers[0] ??
265
+ 'Choose a supported adapter surface and source entry before adapting this folder.'),
266
+ );
267
+ }
268
+ return {
269
+ manifestVersion: 2,
270
+ name: options.name,
271
+ version: '0.1.0',
272
+ engine: { version: options.engineVersion },
273
+ roots: [
274
+ {
275
+ id: 'game',
276
+ adapter: { surface, ingest: {} },
277
+ entry,
278
+ },
279
+ ],
280
+ };
281
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * THE RUN-CONFIGURATION KIND REGISTRY — the open set of kinds a manifest's
3
+ * `configurations[]` may declare (ARCHITECTURE-CORE §The project model). A run
4
+ * configuration is one of the project's ENTRYPOINTS: something the editor,
5
+ * the CLI or a harness can start by id. The engine registers two kinds
6
+ * here; a capability registers more through the same door; the manifest
7
+ * validates each declaration against its kind's own schema at load, and an
8
+ * unregistered kind refuses loudly naming the registered ones.
9
+ *
10
+ * The implicit configuration every project with roots has is `play` — mount
11
+ * every root in the host — which is never declared and may be named by a
12
+ * `compound`.
13
+ */
14
+
15
+ import { type ZodType, z } from 'zod';
16
+
17
+ /** The envelope every declaration carries; the rest is the kind's. */
18
+ export interface ConfigurationEnvelope {
19
+ readonly id: string;
20
+ readonly kind: string;
21
+ }
22
+
23
+ export type ConfigurationRole = 'run' | 'build';
24
+
25
+ export interface ConfigurationKindRegistration<
26
+ T extends ConfigurationEnvelope = ConfigurationEnvelope,
27
+ > {
28
+ readonly kind: T['kind'];
29
+ /** Where a configuration of this kind surfaces and which verb starts it:
30
+ * `run` (the transport) or `build` (Build Profiles, Export). */
31
+ readonly role: ConfigurationRole;
32
+ /** The declaration's full shape, envelope included; `.strict()`. */
33
+ readonly schema: ZodType<T>;
34
+ /** One line for schema output and refusals. */
35
+ readonly describe: string;
36
+ /** A build-role kind backed by a project TOOL (the operation catalog):
37
+ * the host runs `tool` with `input(configuration)` and reports its
38
+ * outcome as the build's. This is how a capability's bake becomes a
39
+ * build without the engine knowing what a bake is. */
40
+ readonly build?: { readonly tool: string; readonly input: (configuration: T) => unknown };
41
+ }
42
+
43
+ /**
44
+ * What a CONTRIBUTION exports to register a kind — a plain object, so a
45
+ * project module (evaluated with the project's own copy of the engine)
46
+ * hands it to the HOST, which registers it in the host's registry. A module
47
+ * `editor/contributions/<name>.kind.ts` exports it as `kind`.
48
+ */
49
+ export type ConfigurationKindContribution = ConfigurationKindRegistration;
50
+
51
+ const CONFIGURATION_ROLES: readonly ConfigurationRole[] = ['run', 'build'];
52
+
53
+ /** Register a contribution's kind after checking its shape by name. */
54
+ export function registerContributedConfigurationKind(value: unknown, source: string): () => void {
55
+ const record = (value ?? {}) as Partial<ConfigurationKindContribution>;
56
+ const problems: string[] = [];
57
+ if (typeof record.kind !== 'string' || record.kind.length === 0)
58
+ problems.push('`kind` must be a non-empty string');
59
+ if (!CONFIGURATION_ROLES.includes(record.role as ConfigurationRole))
60
+ problems.push("`role` must be 'run' or 'build'");
61
+ if (typeof record.describe !== 'string') problems.push('`describe` must be a string');
62
+ const schema = record.schema as { safeParse?: unknown } | undefined;
63
+ if (!schema || typeof schema.safeParse !== 'function')
64
+ problems.push('`schema` must be a Zod schema');
65
+ const build = record.build as { tool?: unknown; input?: unknown } | undefined;
66
+ if (
67
+ build !== undefined &&
68
+ (typeof build.tool !== 'string' || typeof build.input !== 'function')
69
+ ) {
70
+ problems.push('`build` must be `{ tool: string; input(configuration) }`');
71
+ }
72
+ if (problems.length > 0) {
73
+ throw new Error(`${source}: not a configuration kind — ${problems.join('; ')}`);
74
+ }
75
+ return registerConfigurationKind(record as ConfigurationKindContribution);
76
+ }
77
+
78
+ const registry = new Map<string, ConfigurationKindRegistration>();
79
+
80
+ /** Register a kind. A second registration replaces the first (a module
81
+ * re-evaluated on save); never throws. */
82
+ export function registerConfigurationKind<T extends ConfigurationEnvelope>(
83
+ registration: ConfigurationKindRegistration<T>,
84
+ ): () => void {
85
+ const entry = registration as unknown as ConfigurationKindRegistration;
86
+ registry.set(registration.kind, entry);
87
+ return () => {
88
+ if (registry.get(registration.kind) === entry) registry.delete(registration.kind);
89
+ };
90
+ }
91
+
92
+ export function registeredConfigurationKinds(): readonly string[] {
93
+ return [...registry.keys()];
94
+ }
95
+
96
+ export function configurationKind(kind: string): ConfigurationKindRegistration | undefined {
97
+ return registry.get(kind);
98
+ }
99
+
100
+ /** The id of the implicit configuration: mount every root in the host. */
101
+ export const PLAY_CONFIGURATION_ID = 'play';
102
+
103
+ /** A process the project runs beside the host: an entry module started
104
+ * with the project's own toolchain, listening on a port when it has one. */
105
+ export interface ProcessConfiguration extends ConfigurationEnvelope {
106
+ readonly kind: 'process';
107
+ /** Project-relative entry module (`server/main.ts`). */
108
+ readonly entry: string;
109
+ /** The command that starts it, run in a shell at `cwd`; `npx tsx <entry>`
110
+ * when omitted. Declare it when the entry needs its own flags. */
111
+ readonly command?: string;
112
+ /** The port it listens on, when readiness means a port. */
113
+ readonly port?: number;
114
+ /** Project-relative working directory; the project root when omitted. */
115
+ readonly cwd?: string;
116
+ readonly env?: Readonly<Record<string, string>>;
117
+ /** How a runner knows it is up: the port accepting connections (the
118
+ * default when `port` is set), or an HTTP path answering 2xx on it. */
119
+ readonly ready?: { readonly http?: string };
120
+ }
121
+
122
+ /** Several configurations started together, `play` included by name. */
123
+ export interface CompoundConfiguration extends ConfigurationEnvelope {
124
+ readonly kind: 'compound';
125
+ readonly run: readonly string[];
126
+ /** How many instances of the host mount to run against the rest (the
127
+ * editor's Instances picker is this parameter). */
128
+ readonly instances?: number;
129
+ }
130
+
131
+ /** A packaged build of the project's own web bundle: the project's build
132
+ * script, then `dist/` packaged as the web artifact. The first build-role
133
+ * kind; a capability registers more (a bake, a static site). */
134
+ export interface BundleConfiguration extends ConfigurationEnvelope {
135
+ readonly kind: 'bundle';
136
+ /** The package.json script that builds (`build`). */
137
+ readonly script?: string;
138
+ /** Extra arguments after `--` (`['--base', './']` for an itch-style relative bundle). */
139
+ readonly args?: readonly string[];
140
+ }
141
+
142
+ export type DeclaredConfiguration = ConfigurationEnvelope & Readonly<Record<string, unknown>>;
143
+
144
+ registerConfigurationKind<ProcessConfiguration>({
145
+ kind: 'process',
146
+ role: 'run',
147
+ describe: 'A process beside the host: an entry module, a port, readiness',
148
+ schema: z
149
+ .object({
150
+ id: z.string().min(1).describe('Configuration id'),
151
+ kind: z.literal('process'),
152
+ entry: z.string().min(1).describe('Project-relative entry module'),
153
+ command: z
154
+ .string()
155
+ .min(1)
156
+ .optional()
157
+ .describe('Shell command that starts it; `npx tsx <entry>` when omitted'),
158
+ port: z.number().int().min(1).max(65535).optional().describe('Port it listens on'),
159
+ cwd: z.string().min(1).optional().describe('Project-relative working directory'),
160
+ env: z.record(z.string(), z.string()).optional().describe('Environment for the process'),
161
+ ready: z
162
+ .object({ http: z.string().min(1).optional().describe('HTTP path answering 2xx when up') })
163
+ .strict()
164
+ .optional()
165
+ .describe('Readiness beyond the port accepting connections'),
166
+ })
167
+ .strict() as unknown as ZodType<ProcessConfiguration>,
168
+ });
169
+
170
+ registerConfigurationKind<CompoundConfiguration>({
171
+ kind: 'compound',
172
+ role: 'run',
173
+ describe: 'Several configurations started together; `play` names the host mount',
174
+ schema: z
175
+ .object({
176
+ id: z.string().min(1).describe('Configuration id'),
177
+ kind: z.literal('compound'),
178
+ run: z.array(z.string().min(1)).min(1).describe('Configuration ids, `play` included'),
179
+ instances: z
180
+ .number()
181
+ .int()
182
+ .min(1)
183
+ .optional()
184
+ .describe('Instances of the host mount to run against the rest'),
185
+ })
186
+ .strict() as unknown as ZodType<CompoundConfiguration>,
187
+ });
188
+
189
+ registerConfigurationKind<BundleConfiguration>({
190
+ kind: 'bundle',
191
+ role: 'build',
192
+ describe: "The project's web bundle: its build script, then dist/ packaged as the artifact",
193
+ schema: z
194
+ .object({
195
+ id: z.string().min(1).describe('Configuration id'),
196
+ kind: z.literal('bundle'),
197
+ script: z.string().min(1).optional().describe('package.json script that builds (`build`)'),
198
+ args: z.array(z.string()).optional().describe('Arguments passed after `--`'),
199
+ })
200
+ .strict() as unknown as ZodType<BundleConfiguration>,
201
+ });
202
+
203
+ /** Validate `configurations[]` against the registry: every kind registered, every
204
+ * declaration its kind's shape, ids unique, compound members declared. */
205
+ export function configurationIssues(configurations: readonly ConfigurationEnvelope[]): string[] {
206
+ const issues: string[] = [];
207
+ const ids = new Set<string>();
208
+ for (const declaration of configurations) {
209
+ if (ids.has(declaration.id)) issues.push(`configurations: duplicate id "${declaration.id}"`);
210
+ ids.add(declaration.id);
211
+ const registration = registry.get(declaration.kind);
212
+ if (!registration) {
213
+ issues.push(
214
+ `configurations "${declaration.id}": unknown kind "${declaration.kind}" — registered: ${registeredConfigurationKinds()
215
+ .map((k) => `"${k}"`)
216
+ .join(', ')}`,
217
+ );
218
+ continue;
219
+ }
220
+ const parsed = registration.schema.safeParse(declaration);
221
+ if (!parsed.success) {
222
+ for (const issue of parsed.error.issues) {
223
+ issues.push(
224
+ `configurations "${declaration.id}" (${declaration.kind}): ${issue.path.join('.') || '(declaration)'}: ${issue.message}`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+ for (const declaration of configurations) {
230
+ if (declaration.kind !== 'compound') continue;
231
+ const members = (declaration as { run?: readonly string[] }).run ?? [];
232
+ for (const member of members) {
233
+ if (member !== PLAY_CONFIGURATION_ID && !ids.has(member)) {
234
+ issues.push(
235
+ `configurations "${declaration.id}": member "${member}" is not a declared configuration (or "${PLAY_CONFIGURATION_ID}")`,
236
+ );
237
+ }
238
+ }
239
+ }
240
+ return issues;
241
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The editor's built-in port fallback, and the paired HMR-socket derivation.
3
+ *
4
+ * ONE owner for the number, because it is spelled in a launcher (the editor's `edit` command),
5
+ * three servers (dev / prod / packaged), and a client default — a duplicated
6
+ * literal across those drifts silently.
7
+ *
8
+ * **Why not Vite's 5173.** It used to be exactly that, and 5173 is the default
9
+ * of every Vite app on the machine. On a box running any other Vite dev server
10
+ * the engine-repo editor and that app fight over one port, and — worse —
11
+ * `http://…:5173` can resolve to a FOREIGN application. Measured 2026-08-07:
12
+ * an unrelated process on 5173 was what the editor's tab self-heal opened.
13
+ *
14
+ * **The band.** 20000-20199 is Volter's own reserved band: fixed, hand-picked
15
+ * ports that must never be minted for a worktree session. Machine-local
16
+ * allocations start at 20200, so one can never land on this default by chance.
17
+ * 20173 keeps Vite's memorable `173` tail while sitting in a range nothing
18
+ * else claims.
19
+ */
20
+ export const DEFAULT_EDITOR_PORT = 20_173;
21
+
22
+ /**
23
+ * The Vite HMR websocket port paired with an editor port.
24
+ *
25
+ * Editor ports in 20000-29999 map into the parallel 30000-39999 band, so two
26
+ * worktree sessions cannot collide merely because their editor ports differ
27
+ * by 100. Anything outside that band keeps the simple +100 offset.
28
+ *
29
+ * `DEFAULT_EDITOR_PORT` derives 30173 through the first arm — no special case,
30
+ * which is the point: the old expression carried a hardcoded `5173 -> 24678`
31
+ * exception precisely because the default sat outside the band it designed.
32
+ */
33
+ export function editorHmrPort(editorPort: number): number {
34
+ return editorPort >= 20_000 && editorPort < 30_100 ? editorPort + 10_000 : editorPort + 100;
35
+ }
36
+
37
+ /**
38
+ * Wrap a bare IPv6 literal in brackets so it is legal inside a URL authority.
39
+ * `127.0.0.1`, a hostname, and an already-bracketed literal pass through.
40
+ */
41
+ function urlAuthorityHost(host: string): string {
42
+ if (host.startsWith('[')) return host;
43
+ return host.includes(':') ? `[${host}]` : host;
44
+ }
45
+
46
+ /**
47
+ * The ONE composer for an editor origin — every URL volter PRINTS, OPENS,
48
+ * SELF-HEALS with, records in `.volter/session.json`, or dials.
49
+ *
50
+ * **It never says `localhost`.** A bare-`localhost` URL is a different address
51
+ * from the one the server is listening on: on a dual-stack box `localhost`
52
+ * resolves `::1` first while the editor binds `127.0.0.1`, so the URL reaches
53
+ * whatever else happens to hold that port on IPv6. Measured 2026-08-07 — the
54
+ * tab bijection's self-heal opened an UNRELATED application in the owner's
55
+ * browser because it composed its reopen URL from `localhost` instead of the
56
+ * host the server had actually bound.
57
+ *
58
+ * `bindHost` is the host the server bound (`resolveBindHost`); callers that
59
+ * only know a port — the CLI and SDK clients dialing a local session — omit it
60
+ * and get the IPv4 loopback literal. A wildcard bind (`0.0.0.0` / `::`, which
61
+ * is what WSL gets) is not an address a browser can visit, so it degrades to
62
+ * that same literal: reachable from the same box, and reachable from Windows
63
+ * through WSL2's loopback forwarding.
64
+ */
65
+ export function editorOrigin(port: number, bindHost?: string): string {
66
+ const host = (bindHost ?? '').trim();
67
+ const unusable = host === '' || host === '0.0.0.0' || host === '::' || host === '[::]';
68
+ return `http://${unusable ? '127.0.0.1' : urlAuthorityHost(host)}:${port}`;
69
+ }
@@ -0,0 +1,45 @@
1
+ // The project manifest's FILENAME. There is exactly ONE.
2
+ //
3
+ // The manifest is `volter.project.json`, and NOTHING reads a second spelling
4
+ // (the legacy-removal doctrine, `docs/ARCHITECTURE-CORE.md` §Vocabulary). Two
5
+ // accepted names is the defect, not the convenience: both get written, and
6
+ // copies of the "try both" probe spread into modules that agree with the real
7
+ // resolver only by coincidence.
8
+ //
9
+ // The removed name REJECTS LOUDLY, naming the exact one-line fix — never
10
+ // silently ignored, never redirected. A directory
11
+ // carrying only `volter.game.json` is an error with a `git mv` in the message,
12
+ // not a project that quietly fails to open.
13
+ //
14
+ // This module is the ONE place either literal is spelled out, and it is
15
+ // dependency-free (no `node:fs`) so browser bundles can import it. The
16
+ // filesystem side lives in `./locate` (`resolveManifestPath`, `hasManifest`,
17
+ // `assertNoRemovedManifestFilename`).
18
+
19
+ /** The manifest filename. Every reader reads it; every writer writes it. */
20
+ export const MANIFEST_FILENAME = 'volter.project.json';
21
+
22
+ /** The removed filename. Read by nothing — its only job is the error message. */
23
+ export const REMOVED_MANIFEST_FILENAME = 'volter.game.json';
24
+
25
+ /** True for the manifest filename (basename comparison). */
26
+ export function isManifestFilename(name: string): boolean {
27
+ return name === MANIFEST_FILENAME;
28
+ }
29
+
30
+ /**
31
+ * The verbatim rejection an on-disk `volter.game.json` earns — the one-line fix
32
+ * spelled out, per the removed-format contract. Shared by the Node
33
+ * (`./locate`) and browser (storage-backend) halves so a project author sees
34
+ * the same sentence wherever the stale name is found.
35
+ */
36
+ export function removedManifestFilenameMessage(where: string): string {
37
+ return (
38
+ `${where}: found \`${REMOVED_MANIFEST_FILENAME}\` and no \`${MANIFEST_FILENAME}\`. ` +
39
+ `\`${REMOVED_MANIFEST_FILENAME}\` was REMOVED (the legacy-removal doctrine, ` +
40
+ 'docs/ARCHITECTURE-CORE.md §Vocabulary) — nothing reads it any more, and it is ' +
41
+ 'deliberately NOT read as a fallback, because a second accepted name is the defect.\n' +
42
+ `Fix: rename it — \`git mv ${REMOVED_MANIFEST_FILENAME} ${MANIFEST_FILENAME}\`. ` +
43
+ 'The contents are unchanged; only the filename moved.'
44
+ );
45
+ }