animader-cli 0.0.0-stage → 0.1.0

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 (70) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +261 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +601 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +211 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +68 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +129 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/routes.mjs +170 -0
  28. package/lib/score.mjs +434 -0
  29. package/lib/show.mjs +458 -0
  30. package/lib/soundtrack.mjs +67 -0
  31. package/lib/vendor.mjs +99 -0
  32. package/lib/wav.mjs +197 -0
  33. package/package.json +47 -4
  34. package/player/boot.mjs +792 -0
  35. package/player/controller.mjs +366 -0
  36. package/player/devchannel.mjs +306 -0
  37. package/player/index.html +35 -0
  38. package/player/page.mjs +134 -0
  39. package/player/player.css +393 -0
  40. package/player/release.mjs +76 -0
  41. package/src/commands/build.mjs +201 -0
  42. package/src/commands/dev.mjs +214 -0
  43. package/src/commands/doctor.mjs +348 -0
  44. package/src/commands/export.mjs +158 -0
  45. package/src/commands/new.mjs +164 -0
  46. package/src/commands/project.mjs +46 -0
  47. package/src/config.mjs +110 -0
  48. package/src/router.mjs +149 -0
  49. package/src/toolchain.mjs +691 -0
  50. package/starters/circle/README.md +50 -0
  51. package/starters/circle/animader.json +4 -0
  52. package/starters/circle/gitignore +9 -0
  53. package/starters/circle/kernels/circle.wgsl +32 -0
  54. package/starters/circle/program.sjon +34 -0
  55. package/starters/circle/timeline.piece.sjon +26 -0
  56. package/starters/ember/bake/spans.bin +0 -0
  57. package/starters/ember/kernels/curl.wgsl +10 -0
  58. package/starters/ember/kernels/ember.wgsl +10 -0
  59. package/starters/ember/kernels/hash.wgsl +12 -0
  60. package/starters/ember/kernels/simplex.wgsl +27 -0
  61. package/starters/ember/program.sjon +328 -0
  62. package/starters/ember/timeline.piece.sjon +34 -0
  63. package/toolchain/licenses/animader.txt +121 -0
  64. package/toolchain/licenses/esbuild.txt +21 -0
  65. package/toolchain/licenses/pacer.txt +121 -0
  66. package/toolchain/licenses/pngine.txt +121 -0
  67. package/toolchain/manifest.json +212 -0
  68. package/toolchain/prepare.mjs +446 -0
  69. package/toolchain/uniform-type-name.js +184 -0
  70. package/README.md +0 -3
package/lib/new.mjs ADDED
@@ -0,0 +1,304 @@
1
+ // Fresh-project authoring decisions. Portable: paths, starter metadata and the
2
+ // exact write order live here; filesystem access and argv/process wiring come
3
+ // from host adapters. The starter is the same tracked fixture the demo already
4
+ // proves, so a new project needs no prepared vendor bundle or native producer.
5
+ //
6
+ // Honesty boundary: the emptiness check is point-in-time, not a lock. Files are
7
+ // written one at a time with the adapter's atomic per-file write; a write-phase
8
+ // failure leaves complete files behind and names them. The adapter removes only
9
+ // the failed write's own temporary file; an empty parent directory may remain.
10
+ // There is no rollback and no transaction claim.
11
+
12
+ import { PROJECT_FILES } from './contract.mjs';
13
+ import { DemoError, EXIT_CODES, projectError, usageError } from './errors.mjs';
14
+ import { createTestTone } from './wav.mjs';
15
+
16
+ const EMBER_STARTER = new URL('../starters/ember/', import.meta.url).href;
17
+ const CIRCLE_STARTER = new URL('../starters/circle/', import.meta.url).href;
18
+
19
+ const STARTER_FILES = [
20
+ { path: PROJECT_FILES.program, source: 'program.sjon' },
21
+ { path: PROJECT_FILES.timeline, source: 'timeline.piece.sjon' },
22
+ { path: 'kernels/curl.wgsl', source: 'kernels/curl.wgsl' },
23
+ { path: 'kernels/ember.wgsl', source: 'kernels/ember.wgsl' },
24
+ { path: 'kernels/hash.wgsl', source: 'kernels/hash.wgsl' },
25
+ { path: 'kernels/simplex.wgsl', source: 'kernels/simplex.wgsl' },
26
+ { path: 'bake/spans.bin', source: 'bake/spans.bin' },
27
+ { path: PROJECT_FILES.soundtrack, generate: 'test-tone' },
28
+ ].map((entry) => Object.freeze(entry));
29
+
30
+ // The tutorial's end-of-step-6 circle: one kernel, an eight-second path
31
+ // with beat pulses, a complete timeline, plus the project config, the
32
+ // generated-output ignore entries and a README. `gitignore` is the tracked
33
+ // source of the target's `.gitignore` so git does not interpret it here.
34
+ const CIRCLE_FILES = [
35
+ { path: 'animader.json', source: 'animader.json' },
36
+ { path: PROJECT_FILES.program, source: 'program.sjon' },
37
+ { path: PROJECT_FILES.timeline, source: 'timeline.piece.sjon' },
38
+ { path: 'kernels/circle.wgsl', source: 'kernels/circle.wgsl' },
39
+ { path: '.gitignore', source: 'gitignore' },
40
+ { path: 'README.md', source: 'README.md' },
41
+ { path: PROJECT_FILES.soundtrack, generate: 'test-tone' },
42
+ ].map((entry) => Object.freeze(entry));
43
+
44
+ export const STARTERS = Object.freeze({
45
+ circle: Object.freeze({
46
+ name: 'circle',
47
+ sourceRoot: CIRCLE_STARTER,
48
+ files: Object.freeze(CIRCLE_FILES),
49
+ }),
50
+ ember: Object.freeze({
51
+ name: 'ember',
52
+ sourceRoot: EMBER_STARTER,
53
+ files: Object.freeze(STARTER_FILES),
54
+ }),
55
+ });
56
+
57
+ export const STARTER_NAMES = Object.freeze(Object.keys(STARTERS));
58
+
59
+ function starterList() {
60
+ return STARTER_NAMES.join(', ');
61
+ }
62
+
63
+ function inFolder(folder, name) {
64
+ return `${String(folder).replace(/[\\/]+$/, '')}/${name}`;
65
+ }
66
+
67
+ /**
68
+ * Parse the fresh-project argv without touching the filesystem.
69
+ *
70
+ * @param {string[]} argv
71
+ * @returns {{help: boolean, folder: string|null, starter: string}}
72
+ */
73
+ export function parseNewArgv(argv) {
74
+ if (!Array.isArray(argv)) {
75
+ throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call node demo/new.mjs <folder> [--starter ember]');
76
+ }
77
+
78
+ let folder = null;
79
+ let starter = 'ember';
80
+ let help = false;
81
+ let endOptions = false;
82
+
83
+ for (let index = 0; index < argv.length; index++) {
84
+ const argument = String(argv[index]);
85
+ if (!endOptions && argument === '--') {
86
+ endOptions = true;
87
+ continue;
88
+ }
89
+ if (!endOptions && (argument === '--help' || argument === '-h')) {
90
+ help = true;
91
+ continue;
92
+ }
93
+ if (!endOptions && argument.startsWith('--')) {
94
+ const equals = argument.indexOf('=');
95
+ const name = equals === -1 ? argument : argument.slice(0, equals);
96
+ const inline = equals === -1 ? undefined : argument.slice(equals + 1);
97
+ if (name === '--starter') {
98
+ if (inline !== undefined) {
99
+ starter = inline;
100
+ } else {
101
+ if (index + 1 >= argv.length) {
102
+ throw usageError('missing_option_value', `${name} needs a value`, `Pass ${name} ${starterList()}`);
103
+ }
104
+ index++;
105
+ starter = String(argv[index]);
106
+ }
107
+ } else {
108
+ throw usageError(
109
+ 'unknown_option',
110
+ `unknown option ${name}`,
111
+ `The supported option is --starter (available: ${starterList()}); use --help for usage.`,
112
+ );
113
+ }
114
+ continue;
115
+ }
116
+ if (!endOptions && argument.startsWith('-') && argument !== '-') {
117
+ throw usageError(
118
+ 'unknown_option',
119
+ `unknown option ${argument}`,
120
+ `The supported option is --starter (available: ${starterList()}); use --help for usage.`,
121
+ );
122
+ }
123
+ if (folder !== null) {
124
+ throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass exactly one project folder');
125
+ }
126
+ folder = argument;
127
+ }
128
+
129
+ if (help) return { help: true, folder, starter };
130
+ if (folder === null || folder === '') {
131
+ throw usageError(
132
+ 'missing_folder',
133
+ 'exactly one project folder is required',
134
+ 'Run: node demo/new.mjs <folder> [--starter ember]',
135
+ );
136
+ }
137
+ if (!STARTER_NAMES.includes(starter)) {
138
+ throw usageError(
139
+ 'unknown_starter',
140
+ `unknown starter ${JSON.stringify(starter)}`,
141
+ `Available starters: ${starterList()}`,
142
+ );
143
+ }
144
+ return { help: false, folder, starter };
145
+ }
146
+
147
+ export function newUsageText() {
148
+ return [
149
+ 'animader demo new',
150
+ '',
151
+ 'Usage:',
152
+ ' node demo/new.mjs <folder> [--starter ember]',
153
+ '',
154
+ 'Create an editable project from a tracked starter. The folder must not',
155
+ 'exist, or must be an empty directory.',
156
+ '',
157
+ 'Files:',
158
+ ` ${PROJECT_FILES.program}`,
159
+ ` ${PROJECT_FILES.timeline}`,
160
+ ` ${PROJECT_FILES.soundtrack} generated 8.0 s labeled test tone; replace with your audio`,
161
+ ' (later, one of soundtrack.wav, soundtrack.ogg or',
162
+ ' soundtrack.opus — exactly one may exist at a time)',
163
+ ' kernels/{curl,ember,hash,simplex}.wgsl',
164
+ ' bake/spans.bin',
165
+ '',
166
+ 'Options:',
167
+ ` --starter NAME starter to copy (default: ember; available: ${starterList()})`,
168
+ " -- end options; the next argument is the folder even if it starts with '-'",
169
+ ' -h, --help this text',
170
+ '',
171
+ 'A folder symlink is followed. A target inside the tracked starter source is refused.',
172
+ ].join('\n');
173
+ }
174
+
175
+ export function shellQuote(value) {
176
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
177
+ }
178
+
179
+ function targetInspectionFailure(error, folder) {
180
+ return new DemoError('target_inspection_failed', `could not inspect ${folder}: ${error?.message ?? String(error)}`, {
181
+ exitCode: EXIT_CODES.project,
182
+ remedy: 'Check the folder path and its permissions; no files were written.',
183
+ cause: error,
184
+ });
185
+ }
186
+
187
+ async function requireEmptyTarget({ folder, io }) {
188
+ let stats;
189
+ try {
190
+ stats = await io.statFile(folder);
191
+ } catch (error) {
192
+ throw targetInspectionFailure(error, folder);
193
+ }
194
+ if (stats === null) return;
195
+
196
+ if (!stats.isDirectory) {
197
+ throw projectError(
198
+ 'project_folder_not_empty',
199
+ `${folder} exists but is not a directory`,
200
+ 'Pass a folder that does not exist or an empty directory; no files were written.',
201
+ );
202
+ }
203
+
204
+ let entries;
205
+ try {
206
+ entries = await io.listDir(folder);
207
+ } catch (error) {
208
+ throw targetInspectionFailure(error, folder);
209
+ }
210
+ if (entries.length > 0) {
211
+ const first = [...entries].sort()[0];
212
+ throw projectError(
213
+ 'project_folder_not_empty',
214
+ `the project folder is not empty: ${folder} (first conflicting entry: ${first})`,
215
+ 'Choose a folder that does not exist or an empty directory; no files were written.',
216
+ );
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Create one editable project from a frozen starter.
222
+ *
223
+ * @param {Object} options
224
+ * @param {string} options.folder
225
+ * @param {string} options.starter
226
+ * @param {Object} options.io
227
+ * @param {(path: string|URL) => Promise<null|{isFile: boolean, isDirectory: boolean, size: number}>} options.io.statFile
228
+ * @param {(path: string|URL) => Promise<Uint8Array>} options.io.readFile
229
+ * @param {(path: string, bytes: Uint8Array) => Promise<void>} options.io.writeFile
230
+ * @param {(path: string) => Promise<string[]>} options.io.listDir
231
+ * @param {(bytes: Uint8Array) => string} options.io.hashBytes
232
+ */
233
+ export async function createProject({ folder, starter, io }) {
234
+ if (typeof folder !== 'string' || folder === '') {
235
+ throw projectError('missing_folder', 'the project folder is empty', 'Pass the folder to create.');
236
+ }
237
+ if (!STARTER_NAMES.includes(starter)) {
238
+ throw usageError('unknown_starter', `unknown starter ${JSON.stringify(starter)}`, `Available starters: ${starterList()}`);
239
+ }
240
+ if (!io
241
+ || typeof io.statFile !== 'function'
242
+ || typeof io.readFile !== 'function'
243
+ || typeof io.writeFile !== 'function'
244
+ || typeof io.listDir !== 'function'
245
+ || typeof io.hashBytes !== 'function') {
246
+ throw new TypeError('createProject needs io.statFile, io.readFile, io.writeFile, io.listDir and io.hashBytes');
247
+ }
248
+
249
+ await requireEmptyTarget({ folder, io });
250
+
251
+ const definition = STARTERS[starter];
252
+ const staged = [];
253
+ for (const entry of definition.files) {
254
+ let bytes;
255
+ try {
256
+ bytes = entry.generate === 'test-tone'
257
+ ? createTestTone()
258
+ : await io.readFile(new URL(entry.source, definition.sourceRoot));
259
+ } catch (error) {
260
+ throw new DemoError(
261
+ 'starter_source_unreadable',
262
+ `could not read starter source ${entry.source ?? entry.path}: ${error?.message ?? String(error)}`,
263
+ {
264
+ exitCode: EXIT_CODES.project,
265
+ remedy: 'Restore the tracked starter source under the animader-cli package (npm/cli/starters); no files were written.',
266
+ cause: error,
267
+ },
268
+ );
269
+ }
270
+ if (!bytes || typeof bytes.byteLength !== 'number' || bytes.byteLength === 0) {
271
+ throw new DemoError('starter_source_empty', `starter source ${entry.source ?? entry.path} is empty`, {
272
+ exitCode: EXIT_CODES.project,
273
+ remedy: 'Restore the tracked starter source under the animader-cli package (npm/cli/starters); no files were written.',
274
+ });
275
+ }
276
+ staged.push({ path: entry.path, bytes, sha256: io.hashBytes(bytes) });
277
+ }
278
+
279
+ const created = [];
280
+ for (const file of staged) {
281
+ try {
282
+ await io.writeFile(inFolder(folder, file.path), file.bytes);
283
+ } catch (error) {
284
+ const complete = created.length > 0 ? created.map((entry) => entry.path).join(', ') : 'none';
285
+ throw new DemoError(
286
+ 'project_write_failed',
287
+ `writing ${file.path} failed: ${error?.message ?? String(error)}. Complete files already written: ${complete}`,
288
+ {
289
+ exitCode: EXIT_CODES.project,
290
+ remedy: 'The complete files listed above remain on disk and are not rolled back. Inspect the folder and remove it yourself before retrying.',
291
+ cause: error,
292
+ },
293
+ );
294
+ }
295
+ created.push({ path: file.path, bytes: file.bytes.byteLength, sha256: file.sha256 });
296
+ }
297
+
298
+ const contract = {};
299
+ for (const [seat, name] of Object.entries(PROJECT_FILES)) {
300
+ const file = created.find((entry) => entry.path === name);
301
+ contract[seat] = { name, bytes: file.bytes, sha256: file.sha256 };
302
+ }
303
+ return { folder, starter: definition.name, files: created, contract };
304
+ }
@@ -0,0 +1,247 @@
1
+ // The shared full-build pipeline (plan section 4): the export's own stages
2
+ // — graphics bake, program reflection, score derivation and Pacer packing —
3
+ // composed once, plus the playable-by-this-host audit. `animader build`,
4
+ // `animader dev`'s preflight and the release export all call
5
+ // `buildProject`; each caller owns the input snapshot the stages run on and
6
+ // the paths the artifacts land at. There is no second implementation and no
7
+ // child process here: the bake is `bakeProject`, the join is the score
8
+ // module's own `reflectedJoin`, and the audit IS `loadPackedShow` — the
9
+ // same load the release player runs — so a successful `pacer pack` alone
10
+ // can never be reported as a playable build.
11
+ //
12
+ // Every producer is an injected port (`producers.deriveScore`,
13
+ // `reflectPayload`, `packScore`, `pacerRtWasm`, `loadPacerRt`), so this
14
+ // module carries no `node:` import, no process state and no DOM. The audit
15
+ // decision itself is not injectable: it always runs, and a `ShowError` it
16
+ // answers keeps its own code, stage, document and diagnostics in the
17
+ // `DemoError` the CLI reports — the host's identity, never a generic
18
+ // "build failed".
19
+ //
20
+ // Ownership: stage order, the audit-before-receipt rule, the receipt
21
+ // assembly, the drift check between an inspection and its snapshot. WASM
22
+ // bytes, native processes and filesystem IO belong to the adapters.
23
+
24
+ import { bakeProject } from './build.mjs';
25
+ import { buildError } from './errors.mjs';
26
+ import { loadPackedShow, reflectedJoin, ShowError } from './score.mjs';
27
+ import { makeScoreBuildReceipt, serializeScoreBuildReceipt } from './receipt.mjs';
28
+
29
+ /** The stages a full build walks, in order. */
30
+ export const BUILD_STAGES = Object.freeze(['bake', 'derive', 'reflect', 'join', 'pack', 'audit', 'receipt']);
31
+
32
+ /** The conservative input-coverage method this toolchain records today. */
33
+ export const INPUT_SNAPSHOT_METHOD = 'input-snapshot';
34
+
35
+ /**
36
+ * What the receipt's input rows mean when the compiler answers no
37
+ * dependency metadata of its own: every authored file of the filtered
38
+ * input snapshot the build consumed, with the generated output tree, VCS
39
+ * and dependency directories and owned export trees excluded. No second
40
+ * SJON reader exists to narrow this set.
41
+ */
42
+ export const INPUT_SNAPSHOT_NOTE = 'every file of the filtered input snapshot the build consumed (generated out/, .git, node_modules and owned export trees excluded); the compiler answers no dependency metadata of its own, so this conservative set stands in for it';
43
+
44
+ const AUDIT_REMEDY = 'Fix the timeline so the score is playable by this host: every score target must be one the reflected program declares, and automation must stay on the registries this show hosts. This audit is the same load the player runs; it is never skipped.';
45
+
46
+ function wrapShowError(error, stageLabel) {
47
+ if (!(error instanceof ShowError)) return error;
48
+ return buildError(error.code, `${error.message} (${stageLabel} failed at stage '${error.stage}')`, AUDIT_REMEDY);
49
+ }
50
+
51
+ /**
52
+ * The playable-by-this-host audit: load the exact packed bytes through
53
+ * `loadPackedShow` with the playback-only runtime and the baked program's
54
+ * own reflection. A load the host refuses — an unknown target, an
55
+ * unsupported automation shape, a metadata gap — answers a `DemoError`
56
+ * carrying the host's own error code and message.
57
+ *
58
+ * @param {Object} options
59
+ * @param {string} [options.documentName] the artifact name failures keep
60
+ * @param {Uint8Array} options.bytes the packed 'PACB' score
61
+ * @param {Object<string, {type: string, elemCount: number}>} options.reflection
62
+ * @param {Uint8Array} options.pacerRtWasm
63
+ * @param {Function} options.loadPacerRt the `PacerRt.load` shape
64
+ */
65
+ export async function auditPackedScore({ documentName = 'score.pacb', bytes, reflection, pacerRtWasm, loadPacerRt }) {
66
+ try {
67
+ return await loadPackedShow({
68
+ document: { name: documentName, bytes },
69
+ dep: { pacerRtWasm, reflection },
70
+ factories: { loadPacerRt },
71
+ });
72
+ } catch (error) {
73
+ const wrapped = wrapShowError(error, `the playable-by-this-host audit of ${documentName}`);
74
+ if (wrapped === error) throw error;
75
+ wrapped.cause = error;
76
+ throw wrapped;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Refuse a snapshot whose inspected rows drifted from the inspection the
82
+ * caller started from. One implementation for the export's and the build's
83
+ * drift checks; `what` names the failing operation in the error identity.
84
+ *
85
+ * @param {{original: {files: Object}, copied: {files: Object}, what: string}} options
86
+ */
87
+ export async function assertProjectSnapshotsMatch({ original, copied, what }) {
88
+ for (const [seat, row] of Object.entries(original?.files ?? {})) {
89
+ const actual = copied?.files?.[seat];
90
+ if (!row || actual?.name !== row.name || actual?.sha256 !== row.sha256 || actual?.bytes !== row.bytes) {
91
+ throw buildError(
92
+ `${what}_project_drift`,
93
+ `${row?.name ?? seat} changed while the ${what} was preparing`,
94
+ `Re-run the ${what} from a quiet project folder.`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ function portCheck(producers) {
101
+ if (!producers
102
+ || typeof producers.deriveScore !== 'function'
103
+ || typeof producers.reflectPayload !== 'function'
104
+ || typeof producers.packScore !== 'function'
105
+ || typeof producers.pacerRtWasm !== 'function'
106
+ || typeof producers.loadPacerRt !== 'function') {
107
+ throw new TypeError('buildProject needs producers.deriveScore, reflectPayload, packScore, pacerRtWasm and loadPacerRt (the real or fake build ports)');
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Walk the full build over one consistent input snapshot: bake the
113
+ * program, derive the score from the snapshot's timeline, reflect the
114
+ * baked payload, join exactly the targets the program declares, pack the
115
+ * score with the real pacer CLI, audit the packed bytes through the
116
+ * playback runtime, then — only after the audit passes — write the
117
+ * score-build receipt. A failed stage throws before any later stage runs,
118
+ * and a failed audit never leaves a receipt claiming a validated score.
119
+ *
120
+ * @param {Object} options
121
+ * @param {{folder: string, files: Object}} options.project the snapshot's inspection
122
+ * @param {number} options.seconds resolved host show length
123
+ * @param {{folder: string, outDir: string, payload: string, score: string, receipt: string,
124
+ * workDir: string, projectName: string, payloadRelative: string,
125
+ * scoreRelative: string, receiptRelative: string}} options.paths
126
+ * @param {Object} options.io
127
+ * @param {Object} options.native the pngine adapter
128
+ * @param {Object} options.runtime `{ verify }` over the prepared bundle
129
+ * @param {(bytes: Uint8Array) => string} options.hashBytes
130
+ * @param {Array<{path: string, bytes: number, sha256: string}>} options.inputFiles
131
+ * the consumed-input rows the receipt records
132
+ * @param {object} options.producers the build ports
133
+ * @returns {Promise<{stages: string[], payloadBytes: Uint8Array, payloadSha256: string,
134
+ * scoreBytes: Uint8Array, scoreSha256: string, reflection: Object,
135
+ * join: Array<{name: string, ncomp: number}>, receipt: object}>}
136
+ */
137
+ export async function buildProject({
138
+ project,
139
+ seconds,
140
+ paths,
141
+ io,
142
+ native,
143
+ runtime,
144
+ hashBytes,
145
+ inputFiles,
146
+ producers,
147
+ }) {
148
+ portCheck(producers);
149
+
150
+ // bake: the shared graphics bake (verify, clear stale, validate, bake).
151
+ const baked = await bakeProject({
152
+ project,
153
+ paths,
154
+ staleFiles: [paths.payload, paths.score, paths.receipt],
155
+ io,
156
+ native,
157
+ runtime,
158
+ hashBytes,
159
+ });
160
+
161
+ // derive: the piece boundary derives the score from the snapshot's own
162
+ // timeline text; the pipeline never repairs or invents one.
163
+ const timelineBytes = await io.readFile(`${paths.folder}/${project.files.timeline.name}`);
164
+ const timelineText = new TextDecoder().decode(timelineBytes);
165
+ let scoreText;
166
+ try {
167
+ scoreText = await producers.deriveScore(timelineText);
168
+ } catch (error) {
169
+ if (error?.code === 'derive_failed' || error?.code === 'piece_host_failed') throw error;
170
+ throw buildError(
171
+ 'derive_failed',
172
+ `the piece boundary refused the derivation: ${error?.message ?? String(error)}`,
173
+ 'Run the same timeline through the dev player; the build never invents or repairs a score.',
174
+ );
175
+ }
176
+ if (typeof scoreText !== 'string' || scoreText === '') {
177
+ throw buildError('derive_failed', 'the piece boundary answered an empty derived score', 'Inspect the timeline; the build refuses an empty score.');
178
+ }
179
+
180
+ // reflect: the baked program's own uniform table, never a second reader.
181
+ const reflection = await producers.reflectPayload(baked.bytes);
182
+
183
+ // join: exactly the targets the reflection declares, in its table order.
184
+ let join;
185
+ try {
186
+ join = reflectedJoin(reflection, project.files.program.name);
187
+ } catch (error) {
188
+ const wrapped = wrapShowError(error, `the score join for ${project.files.program.name}`);
189
+ wrapped.cause = error;
190
+ throw wrapped;
191
+ }
192
+
193
+ // pack: the real pacer CLI compiles the derived score against that one
194
+ // registry; a refusal (an unknown or ill-typed target) is its own error.
195
+ const packed = await producers.packScore({ scoreText, reflection, outPath: paths.score, workDir: paths.workDir });
196
+ if (!(packed?.bytes instanceof Uint8Array) || packed.bytes.byteLength === 0) {
197
+ throw buildError('pack_failed', 'the pacer pack answered no packed score bytes', 'Inspect the pacer CLI run; a successful pack lands a PACB file.');
198
+ }
199
+ if (!packed.tool || typeof packed.tool.name !== 'string' || packed.tool.name === '' || !/^[0-9a-f]{64}$/.test(String(packed.tool.sha256 ?? ''))) {
200
+ throw buildError('pack_failed', 'the pacer pack answered no tool identity (name and sha256)', 'The pack port must name the CLI it ran; the receipt records it.');
201
+ }
202
+
203
+ // audit: load the exact packed bytes through the playback runtime with
204
+ // the baked program's reflection. Never skipped, never preceded by the
205
+ // receipt.
206
+ const pacerRtWasm = await producers.pacerRtWasm();
207
+ await auditPackedScore({
208
+ documentName: paths.scoreRelative,
209
+ bytes: packed.bytes,
210
+ reflection,
211
+ pacerRtWasm,
212
+ loadPacerRt: producers.loadPacerRt,
213
+ });
214
+
215
+ // receipt: proof of a validated score, written only after the audit.
216
+ const receipt = makeScoreBuildReceipt({
217
+ project,
218
+ projectName: paths.projectName,
219
+ seconds,
220
+ outputs: {
221
+ payload: { name: paths.payloadRelative, bytes: baked.bytes.byteLength, sha256: baked.sha256 },
222
+ score: { name: paths.scoreRelative, bytes: packed.bytes.byteLength, sha256: hashBytes(packed.bytes) },
223
+ },
224
+ inputs: {
225
+ method: INPUT_SNAPSHOT_METHOD,
226
+ note: INPUT_SNAPSHOT_NOTE,
227
+ files: inputFiles,
228
+ },
229
+ runtime: baked.runtime,
230
+ toolchain: {
231
+ pngine: baked.identity,
232
+ pacer: { name: packed.tool.name, sha256: packed.tool.sha256 },
233
+ },
234
+ });
235
+ await io.writeFile(paths.receipt, serializeScoreBuildReceipt(receipt));
236
+
237
+ return {
238
+ stages: [...BUILD_STAGES],
239
+ payloadBytes: baked.bytes,
240
+ payloadSha256: baked.sha256,
241
+ scoreBytes: packed.bytes,
242
+ scoreSha256: hashBytes(packed.bytes),
243
+ reflection,
244
+ join,
245
+ receipt,
246
+ };
247
+ }