@wildshard/shard-format 0.4.5 → 0.4.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildshard/shard-format",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "The raw shardfile format: the spec, the validator and a validate CLI. The public, versioned contract every other repo builds on.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "src/format/*.ts",
20
20
  "!src/format/*.test.ts",
21
21
  "src/format/memoryGates.json",
22
+ "src/tools/runtime-hosts.json",
22
23
  "packages/wire/src/net-command.ts",
23
24
  "src/cli/*.ts",
24
25
  "src/cli/*.mjs",
@@ -115,6 +116,7 @@
115
116
  "./eliteScripts": "./src/format/eliteScripts.ts",
116
117
  "./encounterRows": "./src/format/encounterRows.ts",
117
118
  "./encounters": "./src/format/encounters.ts",
119
+ "./engineMusic": "./src/format/engine-music.ts",
118
120
  "./entryClearance": "./src/format/entryClearance.ts",
119
121
  "./entryCoverage": "./src/format/entryCoverage.ts",
120
122
  "./entryGeometry": "./src/format/entryGeometry.ts",
@@ -161,6 +163,7 @@
161
163
  "./immutable": "./src/format/immutable.ts",
162
164
  "./inputDeclarations": "./src/format/inputDeclarations.ts",
163
165
  "./islandLibrary": "./src/format/islandLibrary.ts",
166
+ "./issue-codes": "./src/format/issue-codes.ts",
164
167
  "./items": "./src/format/items.ts",
165
168
  "./json": "./src/format/json.ts",
166
169
  "./keyedSky": "./src/format/keyedSky.ts",
@@ -182,6 +185,7 @@
182
185
  "./materials": "./src/format/materials.ts",
183
186
  "./memoryAdmission": "./src/format/memoryAdmission.ts",
184
187
  "./memoryGates.json": "./src/format/memoryGates.json",
188
+ "./runtime-hosts.json": "./src/tools/runtime-hosts.json",
185
189
  "./meshCollision": "./src/format/meshCollision.ts",
186
190
  "./meshCollisionAssets": "./src/format/meshCollisionAssets.ts",
187
191
  "./meshCollisionBytes": "./src/format/meshCollisionBytes.ts",
@@ -1,5 +1,8 @@
1
+ import { issueError } from './issue-codes.ts'
2
+
1
3
  import type { RecordingMeasure, RecordingRole } from './audio-measure.ts'
2
4
  import type { AudioWaiver } from './audio-waiver.ts'
5
+ import type { IssueCodeOf } from './issue-codes.ts'
3
6
 
4
7
  // AUDIO AU5 (meta docs/plans/AUDIO-PLAN.md, J299): shardfile owns the audio
5
8
  // rules, and sharddev's `pack` and api-server's admission run this one check.
@@ -16,6 +19,8 @@ import type { AudioWaiver } from './audio-waiver.ts'
16
19
  type AudioLevel = 'green' | 'amber' | 'red'
17
20
 
18
21
  interface AudioFinding {
22
+ /** SHARD-AUTHORING SA41: the stable code (issue-codes.ts, docs/VALIDATION-CODES.md); `message` keeps its words. */
23
+ readonly code: IssueCodeOf<'audio'>
19
24
  readonly level: 'amber' | 'red'
20
25
  readonly rule: 'budget' | 'bitrate' | 'duration' | 'format' | 'waiver'
21
26
  readonly message: string
@@ -86,11 +91,16 @@ function formatProblem(
86
91
  const rate = String(row.sampleRate)
87
92
  if (row.codec === 'wav') {
88
93
  const message = `${row.url} is PCM WAV at ${rate} Hz; ship AAC-LC 48 kHz`
89
- return { level: 'amber', rule: 'format', message }
94
+ return { code: 'audio-wav', level: 'amber', rule: 'format', message }
90
95
  }
91
96
  if (row.sampleRate !== AUDIO_RULES.sampleRate) {
92
97
  const message = `${row.url} is ${rate} Hz AAC; ship 48 kHz`
93
- return { level: 'amber', rule: 'format', message }
98
+ return {
99
+ code: 'audio-sample-rate',
100
+ level: 'amber',
101
+ rule: 'format',
102
+ message,
103
+ }
94
104
  }
95
105
  return undefined
96
106
  }
@@ -99,11 +109,13 @@ function formatFinding(row: Readonly<RecordingMeasure>): AudioFinding[] {
99
109
  if (row.codec === 'unreadable') {
100
110
  const why = row.reason ?? 'unknown'
101
111
  const message = `${row.url} is not AAC-LC 48 kHz mono or stereo (${why})`
102
- return [{ level: 'red', rule: 'format', message }]
112
+ return [{ code: 'audio-unreadable', level: 'red', rule: 'format', message }]
103
113
  }
104
114
  if (row.codec === 'aac' && row.reason !== undefined) {
105
115
  const message = `${row.url} is AAC outside the strict playback index (${row.reason})`
106
- return [{ level: 'amber', rule: 'format', message }]
116
+ return [
117
+ { code: 'audio-strict-index', level: 'amber', rule: 'format', message },
118
+ ]
107
119
  }
108
120
  const problem = formatProblem(row)
109
121
  return problem === undefined ? [] : [problem]
@@ -114,7 +126,7 @@ function durationFinding(row: Readonly<RecordingMeasure>): AudioFinding[] {
114
126
  return []
115
127
  }
116
128
  const message = `${row.url} runs ${row.seconds.toFixed(ONE_DECIMAL)} s, over ${String(AUDIO_RULES.maxSeconds)} s`
117
- return [{ level: 'red', rule: 'duration', message }]
129
+ return [{ code: 'audio-duration', level: 'red', rule: 'duration', message }]
118
130
  }
119
131
 
120
132
  function bitrateFinding(row: Readonly<RecordingMeasure>): AudioFinding[] {
@@ -124,7 +136,7 @@ function bitrateFinding(row: Readonly<RecordingMeasure>): AudioFinding[] {
124
136
  }
125
137
  const layout = row.channels === MONO ? 'mono' : 'stereo'
126
138
  const message = `${row.url} is ${row.kbps.toFixed(ONE_DECIMAL)} kbps ${layout} ${row.role}, over ${String(target)} kbps (+10%)`
127
- return [{ level: 'amber', rule: 'bitrate', message }]
139
+ return [{ code: 'audio-bitrate', level: 'amber', rule: 'bitrate', message }]
128
140
  }
129
141
 
130
142
  function fileFindings(row: Readonly<RecordingMeasure>): AudioFinding[] {
@@ -156,17 +168,26 @@ function redBudget(input: Readonly<BudgetInput>): AudioFinding[] {
156
168
  const over = `${input.slug} holds ${mb(input.bytes)} of its own audio, over the ${mb(AUDIO_RULES.budgetRedBytes)} red line`
157
169
  const { waiver } = input
158
170
  if (waiver === undefined) {
159
- return [{ level: 'red', rule: 'budget', message: over }]
171
+ return [
172
+ { code: 'audio-budget-red', level: 'red', rule: 'budget', message: over },
173
+ ]
160
174
  }
161
175
  const gap = waiverGap(waiver, input)
162
176
  if (gap === undefined) {
163
177
  const message = `${over}; waived until ${waiver.until} (${waiver.reason})`
164
- return [{ level: 'amber', rule: 'budget', message }]
178
+ return [
179
+ { code: 'audio-budget-waived', level: 'amber', rule: 'budget', message },
180
+ ]
165
181
  }
166
182
  const named = `waiver for ${waiver.shard} until ${waiver.until} ${gap}`
167
183
  return [
168
- { level: 'amber', rule: 'waiver', message: named },
169
- { level: 'red', rule: 'budget', message: over },
184
+ {
185
+ code: 'audio-waiver-invalid',
186
+ level: 'amber',
187
+ rule: 'waiver',
188
+ message: named,
189
+ },
190
+ { code: 'audio-budget-red', level: 'red', rule: 'budget', message: over },
170
191
  ]
171
192
  }
172
193
 
@@ -178,7 +199,9 @@ function budgetFindings(input: Readonly<BudgetInput>): AudioFinding[] {
178
199
  return redBudget(input)
179
200
  }
180
201
  const message = `${input.slug} holds ${mb(input.bytes)} of its own audio, over the ${mb(AUDIO_RULES.budgetAmberBytes)} line`
181
- return [{ level: 'amber', rule: 'budget', message }]
202
+ return [
203
+ { code: 'audio-budget-amber', level: 'amber', rule: 'budget', message },
204
+ ]
182
205
  }
183
206
 
184
207
  function levelOf(findings: readonly AudioFinding[]): AudioLevel {
@@ -255,7 +278,8 @@ function assertAudioAdmission(report: Readonly<AudioReport>): void {
255
278
  .filter((row: Readonly<AudioFinding>): boolean => row.level === 'red')
256
279
  .map((row: Readonly<AudioFinding>): string => row.message)
257
280
  if (red.length > NONE) {
258
- throw new Error(
281
+ throw issueError(
282
+ 'audio-refused',
259
283
  `Audio rules (AU5) refuse ${report.slug}: ${red.join('; ')}`,
260
284
  )
261
285
  }
@@ -5,9 +5,10 @@ import memoryGates from './memoryGates.json' with { type: 'json' };
5
5
  export const CHUNK_SIZE = 500; // metres, square
6
6
  /** Half a cell width in metres. */
7
7
  export const CHUNK_HALF: number = CHUNK_SIZE / 2;
8
- /** Signed placement radius of the finite platform catalogue (SF88); residency budgets stay independent. */
9
- export const GRID_RADIUS = 2;
10
- /** Number of placements, including open plots, in the finite square platform. */
8
+ /** Signed placement radius of the finite platform catalogue (SF88): 7×7 since J314, J305's 5×5 at its centre and the
9
+ * outer ring void until a shard is placed there; residency budgets stay independent. */
10
+ export const GRID_RADIUS = 3;
11
+ /** Most placements, including open plots, the finite square platform holds; a grid may leave cells empty. */
11
12
  export const GRID_CELL_COUNT: number = (2 * GRID_RADIUS + 1) ** 2;
12
13
  /** Legal midpoint entry opening in metres. */
13
14
  export const ENTRY_WIDTH = 8;
@@ -223,15 +223,16 @@ const gridShape = strictObject({
223
223
  emptyNeighbour,
224
224
  })
225
225
 
226
- /** Placement data, independent of package content and solo coordinates. */
226
+ /** Placement data, independent of package content and solo coordinates. Since J314 a
227
+ * cell may stay empty: the void a new shard's first upload is placed into. */
227
228
  const GridCatalogueSchema: GenericSchema<GridCatalogueInput, GridCatalogue> =
228
229
  pipe(
229
230
  gridShape,
230
231
  check(
231
232
  // oxlint-disable-next-line typescript/prefer-readonly-parameter-types -- valibot's check takes the schema's own type
232
233
  (grid: InferOutput<typeof gridShape>): boolean =>
233
- uniquePlaces(grid.cells) && grid.cells.length === GRID_CELL_COUNT,
234
- 'unique stable instance identities and complete signed cell layout',
234
+ uniquePlaces(grid.cells),
235
+ 'unique stable instance identities and cells',
235
236
  ),
236
237
  )
237
238
 
@@ -0,0 +1,262 @@
1
+ // SHARD-AUTHORING SA41 (SA21's follow-up, J317 Q19): every issue shard-format reports carries a stable `code`, so a
2
+ // consumer (sharddev's refusal table, api-server) matches the code instead of the message's words. This is the one
3
+ // table; docs/VALIDATION-CODES.md is generated from it (`node src/tools/validation-codes.ts write`) and a test fails when
4
+ // the page drifts. A code is stable: rename one only with a release note, since consumers match it. Messages are not
5
+ // part of the contract and keep their words.
6
+ //
7
+ // Where the code is:
8
+ // - a validator refusal (`validateShardfileProduct`, `validateShardfileStream`, `readTar`): `error.code` on the thrown
9
+ // Error, its message unchanged. The validators tag by the check that refused; a more specific code set nearer the
10
+ // throw wins. A code that is not the table's (Node's `ENOENT` from a host's file port,
11
+ // `ERR_ENCODING_INVALID_ENCODED_DATA` from bad UTF-8) is replaced: such codes differ by host, and the message keeps
12
+ // its words.
13
+ // - a performance report issue: `PerformanceIssue.code`.
14
+ // - an audio finding: `AudioFinding.code`; `assertAudioAdmission`'s refusal: `error.code`.
15
+
16
+ // Validate: both validators, the same code for the same refusal.
17
+ const VALIDATE_CODES = {
18
+ 'archive-invalid':
19
+ 'The tarball is not strict ustar (a bad header, path, padding or end, a truncated entry, too many entries), or the stored object does not decode.',
20
+ 'index-invalid':
21
+ '`shard.json` is missing, too large, not canonical JSON, or fails the index schema.',
22
+ 'file-mismatch':
23
+ 'A listed file is missing, unreadable (a symlink, not a regular file), larger than declared, or its hash or size differs.',
24
+ 'section-invalid':
25
+ 'A section is not canonical JSON, fails its section schema, or is a `client/plan` kind with no declared side.',
26
+ 'script-side':
27
+ 'A script runs on a side its declaration does not allow (SERVER-PARITY SS7).',
28
+ 'script-admission':
29
+ 'The script admission rows the sections declare are invalid.',
30
+ 'blob-invalid':
31
+ "A blob's bytes contradict its listing: kind or extension, registered named JSON and its schema, or costs understated.",
32
+ 'server-file-invalid':
33
+ 'A server file (J91) is unregistered named JSON or its bytes differ from its declared kind.',
34
+ 'ktx2-costs':
35
+ 'The declared KTX2 device costs differ from the verified bytes.',
36
+ 'pack-invalid':
37
+ "A pack is missing a member, or a member or the pack's hash differs.",
38
+ 'cook-invalid':
39
+ 'The cook file, its preparation, or a mount (path or bytes) is invalid (C0, J137).',
40
+ 'runtime-files':
41
+ 'Copied runtime files are present without a runtime import checker, or fail it.',
42
+ 'unlisted-file': 'A file in the product is not listed in `shard.json`.',
43
+ 'shard-schema':
44
+ 'The shardfile assembled from the sections fails the shardfile schema.',
45
+ 'declared-costs':
46
+ 'The declared costs fail preflight: a budget or memory admission over its limit.',
47
+ 'performance-targets':
48
+ 'A declared performance target over its limit refuses (the `REFUSAL:` lines).',
49
+ 'asset-admission':
50
+ "An asset's bytes fail admission: parse, hash or wire size, costs, bounds or script contract.",
51
+ 'world-targets':
52
+ '`world/targets` names an item or quest the shardfile does not declare.',
53
+ 'source-hash': 'The sections differ from the declared `build.sourceHash`.',
54
+ } as const
55
+
56
+ // Stream: the streaming validator's own refusals, beside the validate codes it shares.
57
+ const STREAM_CODES = {
58
+ 'stream-cap':
59
+ 'A member, pack or the placed assets are over the streaming cap.',
60
+ 'stream-miss': 'A check asked for bytes the stream did not hold (AC6a2).',
61
+ 'stream-changed': 'The stored object changed between its reads.',
62
+ } as const
63
+
64
+ // Performance: `PerformanceIssue.code`.
65
+ const PERFORMANCE_CODES = {
66
+ 'perf-memory-target':
67
+ 'A resident memory target (tradeable) is over; always a warning.',
68
+ 'perf-library-download-budget':
69
+ 'The declared library download budget is over the allowance.',
70
+ 'perf-critical-download-budget':
71
+ 'The declared critical (sim) download budget is over its limit.',
72
+ 'perf-library-download':
73
+ "The library closure's download bytes are over the allowance.",
74
+ 'perf-critical-download':
75
+ "The critical closure's download bytes are over the limit.",
76
+ 'perf-tile-download':
77
+ "A tile's download bytes are over its LOD cap (provisional: a warning).",
78
+ 'perf-tile-draws':
79
+ "A tile's draws are over its LOD cap (provisional: a warning).",
80
+ 'perf-tile-triangles':
81
+ "A tile's triangles are over its LOD cap (provisional: a warning).",
82
+ 'perf-far-download': "The far view's download bytes are over the limit.",
83
+ 'perf-far-draws': "The far view's draws are over the limit.",
84
+ 'perf-far-triangles': "The far view's triangles are over the limit.",
85
+ 'perf-native-view-unmeasured':
86
+ 'Native view and download measurement is required and missing.',
87
+ 'perf-native-resident-unmeasured':
88
+ 'The native runtime declares no measured resident cost.',
89
+ 'perf-playing-memory':
90
+ 'The worst playing total bytes are over the limit (report-only under G433: a warning).',
91
+ 'perf-loading-memory':
92
+ 'The worst loading total bytes are over the limit (report-only under G433: a warning).',
93
+ 'perf-script-sample-invalid':
94
+ 'A measured script or fuel figure is not a finite, non-negative number.',
95
+ 'perf-script-cpu': 'Script CPU p95 per tick is over the limit.',
96
+ 'perf-script-fuel': 'Script fuel per tick is over the limit.',
97
+ 'perf-script-samples-missing': 'Fewer than 60 measured script or fuel ticks.',
98
+ } as const
99
+
100
+ // Audio (AUDIO AU5): `AudioFinding.code`, and the admission refusal.
101
+ const AUDIO_CODES = {
102
+ 'audio-unreadable':
103
+ 'A recording is not readable as AAC-LC 48 kHz mono or stereo (red).',
104
+ 'audio-strict-index':
105
+ 'A recording is AAC outside the strict playback index (amber).',
106
+ 'audio-wav': 'A recording is PCM WAV; ship AAC-LC 48 kHz (amber).',
107
+ 'audio-sample-rate':
108
+ 'A recording is AAC at a sample rate other than 48 kHz (amber).',
109
+ 'audio-duration': 'A recording runs over the maximum seconds (red).',
110
+ 'audio-bitrate':
111
+ "A recording's bitrate is over its role and channel cap plus slack (amber).",
112
+ 'audio-budget-amber':
113
+ "The shard's own audio bytes are over the amber line (amber).",
114
+ 'audio-budget-red':
115
+ "The shard's own audio bytes are over the red line, with no waiver that holds (red).",
116
+ 'audio-budget-waived': 'Over the red line, but a dated waiver holds (amber).',
117
+ 'audio-waiver-invalid':
118
+ 'The waiver names another shard, has expired, or covers fewer bytes (amber; the red budget finding follows).',
119
+ 'audio-refused':
120
+ '`assertAudioAdmission` refuses a report with a red finding.',
121
+ } as const
122
+
123
+ /** Which report a code appears in. */
124
+ type IssueFamily = 'validate' | 'stream' | 'performance' | 'audio'
125
+
126
+ /** Every family's codes and what each means, in table order. */
127
+ const ISSUE_FAMILIES: {
128
+ readonly validate: typeof VALIDATE_CODES
129
+ readonly stream: typeof STREAM_CODES
130
+ readonly performance: typeof PERFORMANCE_CODES
131
+ readonly audio: typeof AUDIO_CODES
132
+ } = {
133
+ validate: VALIDATE_CODES,
134
+ stream: STREAM_CODES,
135
+ performance: PERFORMANCE_CODES,
136
+ audio: AUDIO_CODES,
137
+ }
138
+
139
+ /** The codes of one family. */
140
+ type IssueCodeOf<Family extends IssueFamily> = Family extends IssueFamily
141
+ ? keyof (typeof ISSUE_FAMILIES)[Family]
142
+ : never
143
+ /** A stable issue code. */
144
+ type IssueCode = IssueCodeOf<IssueFamily>
145
+
146
+ /** One row of the table. */
147
+ interface IssueCodeRow {
148
+ readonly code: IssueCode
149
+ readonly family: IssueFamily
150
+ readonly meaning: string
151
+ }
152
+
153
+ /** A key of a family's own table, as the code it is. */
154
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only ever given the keys of ISSUE_FAMILIES' tables
155
+ const asCode = (code: string): IssueCode => code as IssueCode
156
+
157
+ const FAMILY_ORDER: readonly IssueFamily[] = [
158
+ 'validate',
159
+ 'stream',
160
+ 'performance',
161
+ 'audio',
162
+ ]
163
+ const ROWS: readonly IssueCodeRow[] = FAMILY_ORDER.flatMap(
164
+ (family: IssueFamily): IssueCodeRow[] => {
165
+ const table: Readonly<Record<string, string>> = ISSUE_FAMILIES[family]
166
+ return Object.entries(table).map(
167
+ ([code, meaning]: readonly [string, string]): IssueCodeRow => ({
168
+ code: asCode(code),
169
+ family,
170
+ meaning,
171
+ }),
172
+ )
173
+ },
174
+ )
175
+ const KNOWN: ReadonlySet<string> = new Set(
176
+ ROWS.map((row: IssueCodeRow): string => row.code),
177
+ )
178
+
179
+ /** Every code, in table order. */
180
+ function issueCodeRows(): readonly IssueCodeRow[] {
181
+ return ROWS
182
+ }
183
+
184
+ /** Whether a string is one of the table's codes. */
185
+ function isIssueCode(value: unknown): value is IssueCode {
186
+ return typeof value === 'string' && KNOWN.has(value)
187
+ }
188
+
189
+ /** The issue code an error carries, if it is one of the table's. */
190
+ function issueCodeOf(error: unknown): IssueCode | undefined {
191
+ const code: unknown =
192
+ typeof error === 'object' && error !== null && 'code' in error
193
+ ? error.code
194
+ : undefined
195
+ return isIssueCode(code) ? code : undefined
196
+ }
197
+
198
+ /** Give `error` this code unless it already carries one of the table's (a nearer tag); returns it. An own property, so a
199
+ * read-only `code` getter (a DOMException's) is shadowed rather than thrown on; an error that refuses even that (frozen)
200
+ * is returned untouched, never replaced. */
201
+ function tagIssue(error: unknown, code: IssueCode): unknown {
202
+ if (error instanceof Error && issueCodeOf(error) === undefined) {
203
+ try {
204
+ Object.defineProperty(error, 'code', {
205
+ value: code,
206
+ writable: true,
207
+ enumerable: true,
208
+ configurable: true,
209
+ })
210
+ } catch {
211
+ // A frozen error keeps its own shape; the refusal still surfaces with its words.
212
+ }
213
+ }
214
+ return error
215
+ }
216
+
217
+ /** A new Error with this message and code. */
218
+ function issueError(
219
+ code: IssueCode,
220
+ message: string,
221
+ ): Error & { readonly code: IssueCode } {
222
+ return Object.assign(new Error(message), { code })
223
+ }
224
+
225
+ /** A tarball framing refusal (`tar: ...`): `archive-invalid`. */
226
+ function archiveError(message: string): Error & { readonly code: IssueCode } {
227
+ return issueError('archive-invalid', message)
228
+ }
229
+
230
+ /** Run one check; a refusal it throws carries `code` unless it already has one of the table's. */
231
+ function withIssueCode<Result>(code: IssueCode, run: () => Result): Result {
232
+ try {
233
+ return run()
234
+ } catch (error) {
235
+ throw tagIssue(error, code)
236
+ }
237
+ }
238
+
239
+ /** `withIssueCode` for an async check. */
240
+ async function withIssueCodeAsync<Result>(
241
+ code: IssueCode,
242
+ run: () => Promise<Result>,
243
+ ): Promise<Result> {
244
+ try {
245
+ return await run()
246
+ } catch (error) {
247
+ throw tagIssue(error, code)
248
+ }
249
+ }
250
+
251
+ export type { IssueCode, IssueCodeOf, IssueCodeRow, IssueFamily }
252
+ export {
253
+ ISSUE_FAMILIES,
254
+ archiveError,
255
+ isIssueCode,
256
+ issueCodeOf,
257
+ issueCodeRows,
258
+ issueError,
259
+ tagIssue,
260
+ withIssueCode,
261
+ withIssueCodeAsync,
262
+ }
@@ -8,6 +8,7 @@ import {
8
8
  assetAdmission,
9
9
  preflightDeclaredCosts,
10
10
  } from './assetAdmission.ts'
11
+ import { issueError, withIssueCode } from './issue-codes.ts'
11
12
  import { assembleOutputShardfile } from './outputIndex.ts'
12
13
  import { offlineMemoryAdmission } from './product.ts'
13
14
  import { assertProductTargets, checkWorldTargets } from './productValidation.ts'
@@ -26,9 +27,15 @@ function admitHead(
26
27
  index: OutputIndex,
27
28
  payloads: ReadonlyMap<string, unknown>,
28
29
  ): Shardfile {
29
- const shard = parseShardfile(assembleOutputShardfile(index, payloads))
30
- preflightDeclaredCosts(shard, offlineMemoryAdmission('warn'))
31
- assertProductTargets(shard, 'warn')
30
+ const shard = withIssueCode('shard-schema', (): Shardfile =>
31
+ parseShardfile(assembleOutputShardfile(index, payloads)),
32
+ )
33
+ withIssueCode('declared-costs', (): void => {
34
+ preflightDeclaredCosts(shard, offlineMemoryAdmission('warn'))
35
+ })
36
+ withIssueCode('performance-targets', (): void => {
37
+ assertProductTargets(shard, 'warn')
38
+ })
32
39
  return shard
33
40
  }
34
41
 
@@ -44,7 +51,9 @@ function admitBeforeAssets(
44
51
  try {
45
52
  const head = admitHead(index, payloads)
46
53
  return {
47
- admission: assetAdmission(head, offlineMemoryAdmission('warn'), scripts),
54
+ admission: withIssueCode('asset-admission', (): AssetAdmission =>
55
+ assetAdmission(head, offlineMemoryAdmission('warn'), scripts),
56
+ ),
48
57
  refusal: undefined,
49
58
  }
50
59
  } catch (error) {
@@ -63,18 +72,20 @@ function admitAssets(
63
72
  if (admission === undefined) {
64
73
  throw before.refusal
65
74
  }
66
- admitCommonsBytes(admission, later.assets, hash)
67
- const refused = later.fileErrors.find(
68
- (error: string | undefined): boolean => error !== undefined,
69
- )
70
- if (refused !== undefined) {
71
- throw new Error(refused)
72
- }
73
- return admitAfterFiles(
74
- admission,
75
- later.assets,
76
- offlineMemoryAdmission('warn'),
77
- )
75
+ return withIssueCode('asset-admission', (): Shardfile => {
76
+ admitCommonsBytes(admission, later.assets, hash)
77
+ const refused = later.fileErrors.find(
78
+ (error: string | undefined): boolean => error !== undefined,
79
+ )
80
+ if (refused !== undefined) {
81
+ throw new Error(refused)
82
+ }
83
+ return admitAfterFiles(
84
+ admission,
85
+ later.assets,
86
+ offlineMemoryAdmission('warn'),
87
+ )
88
+ })
78
89
  }
79
90
 
80
91
  /** `validateShardfileProduct`'s steps after the assets: the world targets and the source hash. */
@@ -93,7 +104,10 @@ function admitTail(
93
104
  product.hash(validationSourceBytes(shard)) !==
94
105
  product.index.build.sourceHash
95
106
  ) {
96
- throw new Error('Output sections differ from the declared source hash')
107
+ throw issueError(
108
+ 'source-hash',
109
+ 'Output sections differ from the declared source hash',
110
+ )
97
111
  }
98
112
  }
99
113
 
@@ -13,6 +13,7 @@
13
13
  // (product-stream-packs.ts).
14
14
 
15
15
  import { admitFileBytes } from './assetAdmission.ts'
16
+ import { issueError, withIssueCodeAsync } from './issue-codes.ts'
16
17
  import { StreamMissError } from './product-stream-miss.ts'
17
18
  import { packGroups, packPass } from './product-stream-packs.ts'
18
19
  import { walkTar } from './product-stream-tar.ts'
@@ -327,7 +328,10 @@ function streamedMember(
327
328
  ): void {
328
329
  state.seen += NEXT
329
330
  if (state.plan.members.get(path)?.sha !== found.sha) {
330
- throw new Error('stream: the stored object changed between reads')
331
+ throw issueError(
332
+ 'stream-changed',
333
+ 'stream: the stored object changed between reads',
334
+ )
331
335
  }
332
336
  const { bytes } = found
333
337
  if (bytes !== undefined) {
@@ -366,7 +370,10 @@ async function oneRead(
366
370
  },
367
371
  })
368
372
  if (state.seen !== plan.members.size) {
369
- throw new Error('stream: the stored object changed between reads')
373
+ throw issueError(
374
+ 'stream-changed',
375
+ 'stream: the stored object changed between reads',
376
+ )
370
377
  }
371
378
  return { peak: state.peak, verdicts: packs.finish() }
372
379
  }
@@ -411,7 +418,10 @@ function placedPaths(
411
418
  }
412
419
  }
413
420
  if (bytes > PLACED_HOLD_CAP) {
414
- throw new Error('stream: the placed assets are over the streaming cap')
421
+ throw issueError(
422
+ 'stream-cap',
423
+ 'stream: the placed assets are over the streaming cap',
424
+ )
415
425
  }
416
426
  return keep
417
427
  }
@@ -442,9 +452,8 @@ function assetPlan(base: AssetPassInput): AssetPlan {
442
452
  }
443
453
  }
444
454
 
445
- /** The later reads: the asset checks and carried packs with the first group of left-out packs, then one read per
446
- * further group. Without an admission (the shard failed before its bytes), only the packs. */
447
- async function assetPasses(base: AssetPassInput): Promise<AssetPasses> {
455
+ /** `assetPasses`, before its refusals take `pack-invalid`. */
456
+ async function passes(base: AssetPassInput): Promise<AssetPasses> {
448
457
  const plan = assetPlan(base)
449
458
  const order = [...plan.members.keys()]
450
459
  const sizes = new Map(
@@ -489,5 +498,14 @@ async function assetPasses(base: AssetPassInput): Promise<AssetPasses> {
489
498
  }
490
499
  }
491
500
 
501
+ /** The later reads: the asset checks and carried packs with the first group of left-out packs, then one read per
502
+ * further group. Without an admission (the shard failed before its bytes), only the packs. */
503
+ async function assetPasses(base: AssetPassInput): Promise<AssetPasses> {
504
+ return await withIssueCodeAsync(
505
+ 'pack-invalid',
506
+ async (): Promise<AssetPasses> => await passes(base),
507
+ )
508
+ }
509
+
492
510
  export type { AssetPassInput, AssetPasses, StoredSource }
493
511
  export { assetPasses }
@@ -3,6 +3,9 @@
3
3
  // check, and the corpus and pin tests prove no check does.
4
4
 
5
5
  class StreamMissError extends Error {
6
+ /** SHARD-AUTHORING SA41: the issue code (issue-codes.ts). */
7
+ public readonly code = 'stream-miss'
8
+
6
9
  public constructor(message: string) {
7
10
  super(message)
8
11
  this.name = 'StreamMissError'
@@ -8,6 +8,8 @@
8
8
  // The packs are split into groups whose held bytes stay under a cap, and
9
9
  // each group takes its own read of the stored object.
10
10
 
11
+ import { issueError } from './issue-codes.ts'
12
+
11
13
  import type { OutputIndex } from './outputIndex.ts'
12
14
  import type { StreamHasher } from './product-stream-tar.ts'
13
15
 
@@ -132,7 +134,10 @@ function packGroups(
132
134
  group.length === NEXT &&
133
135
  heldPeak([entryPaths(pack, paths)], order, sizes) > PACK_HOLD_CAP
134
136
  ) {
135
- throw new Error(`stream: a pack over the streaming cap: ${pack.path}`)
137
+ throw issueError(
138
+ 'stream-cap',
139
+ `stream: a pack over the streaming cap: ${pack.path}`,
140
+ )
136
141
  }
137
142
  }
138
143
  }