@rpgm-tools/neo-angband-mod-sdk 0.10.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.
- package/LICENSE.md +43 -0
- package/README.md +72 -0
- package/dist/capabilities.d.ts +116 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +170 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/compose.d.ts +71 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +118 -0
- package/dist/compose.js.map +1 -0
- package/dist/conflicts.d.ts +78 -0
- package/dist/conflicts.d.ts.map +1 -0
- package/dist/conflicts.js +160 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +83 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +314 -0
- package/dist/loader.js.map +1 -0
- package/dist/manifest.d.ts +261 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +264 -0
- package/dist/manifest.js.map +1 -0
- package/dist/patch.d.ts +90 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +195 -0
- package/dist/patch.js.map +1 -0
- package/dist/record-key.d.ts +99 -0
- package/dist/record-key.d.ts.map +1 -0
- package/dist/record-key.js +157 -0
- package/dist/record-key.js.map +1 -0
- package/dist/resolve.d.ts +42 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +161 -0
- package/dist/resolve.js.map +1 -0
- package/dist/semver.d.ts +37 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +212 -0
- package/dist/semver.js.map +1 -0
- package/package.json +58 -0
- package/src/capabilities.ts +205 -0
- package/src/compose.ts +186 -0
- package/src/conflicts.ts +242 -0
- package/src/index.ts +73 -0
- package/src/loader.ts +393 -0
- package/src/manifest.ts +523 -0
- package/src/patch.ts +257 -0
- package/src/record-key.ts +180 -0
- package/src/resolve.ts +175 -0
- package/src/semver.ts +231 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-pack loading: turn a resolved set of packs into the merged per-file
|
|
3
|
+
* record arrays a host binds into the running game.
|
|
4
|
+
*
|
|
5
|
+
* This is the join MOD_INTEGRATION_PLAN.md (Wave 1, W1.1) calls for. Until now
|
|
6
|
+
* the composition engine (resolveLoadOrder + composePacks) had no runtime
|
|
7
|
+
* caller: the game bound a single hard-coded pack directly. This wraps the
|
|
8
|
+
* engine into one entry point so the base game and every mod flow through the
|
|
9
|
+
* same pipeline, and a mod's records / patches / replaces / removes /
|
|
10
|
+
* fieldPatches actually take effect.
|
|
11
|
+
*
|
|
12
|
+
* The host (web / cli) owns the glue: it reads packs off disk or bundle, calls
|
|
13
|
+
* composeContentPacks, assembles its GamePack from the result, and hands that
|
|
14
|
+
* to core bindCore. Core stays mod-sdk-agnostic (it only ever sees a merged
|
|
15
|
+
* pack), so the layering the audit relied on is preserved.
|
|
16
|
+
*
|
|
17
|
+
* TWO MERGE PHASES, AND WHY
|
|
18
|
+
*
|
|
19
|
+
* composePacks needs a unique string `name` on every added record, because that
|
|
20
|
+
* is the identity it builds refs from. Measured over the shipped core pack, 24
|
|
21
|
+
* of the 44 record files satisfy that and 20 do not (14 carry no string `name`
|
|
22
|
+
* at all; 6 carry names that slug to the same ref). So:
|
|
23
|
+
*
|
|
24
|
+
* 1. COMPOSED FILES (24) go through composePacks, which merges records
|
|
25
|
+
* per-record and appends a later pack's additions after an earlier pack's.
|
|
26
|
+
* 2. PASSTHROUGH FILES (20) keep whole-file semantics for `records` - the last
|
|
27
|
+
* provider in load order wins the file - because a mod that ships
|
|
28
|
+
* `constants.json` means "use mine", not "add a second constants record",
|
|
29
|
+
* and the host binds one. Per-record ops are then applied on top, in load
|
|
30
|
+
* order, against the winning array (applyPassthroughOps below), using the
|
|
31
|
+
* per-file identity declared in record-key.ts.
|
|
32
|
+
*
|
|
33
|
+
* NOTHING IS DROPPED IN SILENCE. This is the invariant the whole file exists to
|
|
34
|
+
* hold. Until 2026-07-29 phase 2 did not exist: a `patches` / `replaces` /
|
|
35
|
+
* `fieldPatches` / `removes` entry aimed at any of the 20 was stripped from the
|
|
36
|
+
* contribution before compose ever saw it, so a mod author shipped a valid op
|
|
37
|
+
* and got no effect and no message - the worst failure mode there is, because a
|
|
38
|
+
* feature that appears to work cannot be found by review. Now every per-record
|
|
39
|
+
* op either takes effect or produces a line in `ComposedContent.problems`
|
|
40
|
+
* naming the pack, the file, the op and the ref. Whole-file replacement of a
|
|
41
|
+
* passthrough file is reported the same way, because it silently discards
|
|
42
|
+
* whatever the previous provider (usually core) put there.
|
|
43
|
+
*
|
|
44
|
+
* `problems` is reported rather than thrown because the web host composes at
|
|
45
|
+
* module scope with no try (packages/web/src/pack.ts), so a throw here is a
|
|
46
|
+
* blank page rather than a message. It is the same shape of channel the pack
|
|
47
|
+
* readers already use (`problems: readonly string[]`), so a host concatenates it
|
|
48
|
+
* into the list it already shows.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import type { PackManifest } from "./manifest.js";
|
|
52
|
+
import { slugify } from "./manifest.js";
|
|
53
|
+
import { resolveLoadOrder } from "./resolve.js";
|
|
54
|
+
import { composePacks, mergePatch } from "./compose.js";
|
|
55
|
+
import type { FileContribution, JsonRecord, PackContent } from "./compose.js";
|
|
56
|
+
import { applyFieldPatch } from "./patch.js";
|
|
57
|
+
import { keyDescription, keySpecFor, recordKey, RECORD_KEY_SPECS } from "./record-key.js";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One pack as the host loaded it: its manifest plus its per-file contributions.
|
|
61
|
+
* The base game is the degenerate case where every file is records-only.
|
|
62
|
+
*/
|
|
63
|
+
export interface LoadedPack {
|
|
64
|
+
manifest: PackManifest;
|
|
65
|
+
/** fileName -> that file's contribution (records / patches / ...). */
|
|
66
|
+
files: Record<string, FileContribution>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The merged content: per-file record arrays, in deterministic order. */
|
|
70
|
+
export interface ComposedContent {
|
|
71
|
+
/** fileName -> composed record array. */
|
|
72
|
+
records: Record<string, unknown[]>;
|
|
73
|
+
/** Files merged per-record through the full FileContribution model. */
|
|
74
|
+
composedFiles: string[];
|
|
75
|
+
/** Files whose `records` pass through last-wins (nameless or name-colliding). */
|
|
76
|
+
passthroughFiles: string[];
|
|
77
|
+
/**
|
|
78
|
+
* Every mod-facing operation that could NOT be honoured, in one line each,
|
|
79
|
+
* naming the pack, the file, the op and the record. Empty for a clean set.
|
|
80
|
+
* A host shows these next to the pack-reading problems it already collects.
|
|
81
|
+
*/
|
|
82
|
+
problems: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isNamedRecord(r: unknown): r is JsonRecord {
|
|
86
|
+
return (
|
|
87
|
+
typeof r === "object" &&
|
|
88
|
+
r !== null &&
|
|
89
|
+
!Array.isArray(r) &&
|
|
90
|
+
typeof (r as { name?: unknown }).name === "string" &&
|
|
91
|
+
(r as { name: string }).name.length > 0
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A pack's added records for one file are per-record composable only if they
|
|
97
|
+
* are all name-keyed and their refs (pack:slug(name)) do not collide - exactly
|
|
98
|
+
* the two conditions composePacks would otherwise throw on.
|
|
99
|
+
*/
|
|
100
|
+
function recordsComposable(records: readonly unknown[]): boolean {
|
|
101
|
+
const slugs = new Set<string>();
|
|
102
|
+
for (const r of records) {
|
|
103
|
+
if (!isNamedRecord(r)) return false;
|
|
104
|
+
const slug = slugify(r["name"] as string);
|
|
105
|
+
if (slugs.has(slug)) return false;
|
|
106
|
+
slugs.add(slug);
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reorder loaded packs into resolved load order (dependencies first). */
|
|
112
|
+
function orderPacks(packs: readonly LoadedPack[]): LoadedPack[] {
|
|
113
|
+
const ordered = resolveLoadOrder(packs.map((p) => p.manifest));
|
|
114
|
+
const byId = new Map(packs.map((p) => [p.manifest.id, p]));
|
|
115
|
+
return ordered.map((m) => byId.get(m.id) as LoadedPack);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The pack id a ref's "<owner>:<slug>" prefix names. */
|
|
119
|
+
function ownerOf(ref: string): string {
|
|
120
|
+
const at = ref.indexOf(":");
|
|
121
|
+
return at === -1 ? "" : ref.slice(0, at);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** compose.ts mayModify: a pack may only touch its own or a declared dep's records. */
|
|
125
|
+
function mayModify(m: PackManifest, ownerPack: string): boolean {
|
|
126
|
+
return ownerPack === m.id || (m.dependencies ?? {})[ownerPack] !== undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The four per-record op kinds, in the order composePacks applies them. */
|
|
130
|
+
type OpKind = "patch" | "replace" | "fieldPatch" | "remove";
|
|
131
|
+
|
|
132
|
+
/** Every per-record op in one contribution, flattened, in apply order. */
|
|
133
|
+
function perRecordOps(
|
|
134
|
+
contrib: FileContribution,
|
|
135
|
+
): Array<{ kind: OpKind; ref: string }> {
|
|
136
|
+
const out: Array<{ kind: OpKind; ref: string }> = [];
|
|
137
|
+
for (const ref of Object.keys(contrib.patches ?? {})) out.push({ kind: "patch", ref });
|
|
138
|
+
for (const ref of Object.keys(contrib.replaces ?? {})) out.push({ kind: "replace", ref });
|
|
139
|
+
for (const ref of Object.keys(contrib.fieldPatches ?? {})) {
|
|
140
|
+
out.push({ kind: "fieldPatch", ref });
|
|
141
|
+
}
|
|
142
|
+
for (const ref of contrib.removes ?? []) out.push({ kind: "remove", ref });
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const OP_VERB: Readonly<Record<OpKind, string>> = {
|
|
147
|
+
patch: "patches",
|
|
148
|
+
replace: "replaces",
|
|
149
|
+
fieldPatch: "fieldPatches",
|
|
150
|
+
remove: "removes",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply every pack's per-record ops to a PASSTHROUGH file's winning record
|
|
155
|
+
* array, in load order, and report every op that could not be honoured.
|
|
156
|
+
*
|
|
157
|
+
* Returns the (possibly new) record array; the input is never mutated, and when
|
|
158
|
+
* no pack contributes an op the input array is returned unchanged so routing the
|
|
159
|
+
* base game alone through this path stays a no-op by reference.
|
|
160
|
+
*/
|
|
161
|
+
function applyPassthroughOps(
|
|
162
|
+
file: string,
|
|
163
|
+
records: readonly unknown[],
|
|
164
|
+
ordered: readonly LoadedPack[],
|
|
165
|
+
providerId: string,
|
|
166
|
+
problems: string[],
|
|
167
|
+
): unknown[] {
|
|
168
|
+
const hasOps = ordered.some(
|
|
169
|
+
(p) => p.files[file] !== undefined && perRecordOps(p.files[file] as FileContribution).length > 0,
|
|
170
|
+
);
|
|
171
|
+
if (!hasOps) return records as unknown[];
|
|
172
|
+
|
|
173
|
+
/* No declared identity (history: chart/next/roll/phrase are all values a mod
|
|
174
|
+
* would change). Every op is reported and none is applied - the one honest
|
|
175
|
+
* answer, because inventing a key here would mis-merge instead of dropping. */
|
|
176
|
+
if (RECORD_KEY_SPECS[file] === undefined && !recordsKeyedByName(records)) {
|
|
177
|
+
for (const pack of ordered) {
|
|
178
|
+
const contrib = pack.files[file];
|
|
179
|
+
if (!contrib) continue;
|
|
180
|
+
for (const { kind, ref } of perRecordOps(contrib)) {
|
|
181
|
+
problems.push(
|
|
182
|
+
`${pack.manifest.id}: ${file} ${OP_VERB[kind]} "${ref}", but ${file} records have no per-record identity, so only whole-file replacement can change them`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return records as unknown[];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const spec = keySpecFor(file);
|
|
190
|
+
const working: Array<JsonRecord | null> = records.map((r) =>
|
|
191
|
+
typeof r === "object" && r !== null && !Array.isArray(r) ? (r as JsonRecord) : null,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/* ref -> EVERY position claiming it. Kept as a list rather than resolved to a
|
|
195
|
+
* winner on insert, so "not found" and "claimed twice" are one lookup with two
|
|
196
|
+
* outcomes: there is nowhere for a first-claim-wins fallback to hide. A ref two
|
|
197
|
+
* records claim is unaddressable and reported; both records stay in the game. */
|
|
198
|
+
const claims = new Map<string, number[]>();
|
|
199
|
+
records.forEach((record, i) => {
|
|
200
|
+
const key = recordKey(file, record, spec);
|
|
201
|
+
if (key === null) return; // unkeyable record: stays in the game, not addressable
|
|
202
|
+
const ref = `${providerId}:${key}`;
|
|
203
|
+
const at = claims.get(ref);
|
|
204
|
+
if (at) at.push(i);
|
|
205
|
+
else claims.set(ref, [i]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const removed = new Set<number>();
|
|
209
|
+
|
|
210
|
+
const reject = (pid: string, kind: OpKind, ref: string, why: string): void => {
|
|
211
|
+
problems.push(`${pid}: ${file} ${OP_VERB[kind]} "${ref}", but ${why}`);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Resolve a ref to a live index, or report why it cannot be touched. */
|
|
215
|
+
const resolve = (
|
|
216
|
+
pack: LoadedPack,
|
|
217
|
+
kind: OpKind,
|
|
218
|
+
ref: string,
|
|
219
|
+
): number | null => {
|
|
220
|
+
const pid = pack.manifest.id;
|
|
221
|
+
const claimants = claims.get(ref) ?? [];
|
|
222
|
+
if (claimants.length > 1) {
|
|
223
|
+
reject(
|
|
224
|
+
pid,
|
|
225
|
+
kind,
|
|
226
|
+
ref,
|
|
227
|
+
`${claimants.length} ${file} records share that identity (${keyDescription(file)}), so it cannot be addressed - patch a record with a unique identity instead`,
|
|
228
|
+
);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const at = claimants[0];
|
|
232
|
+
if (at === undefined) {
|
|
233
|
+
reject(
|
|
234
|
+
pid,
|
|
235
|
+
kind,
|
|
236
|
+
ref,
|
|
237
|
+
`no such record exists in ${file} (identity is ${keyDescription(file)})`,
|
|
238
|
+
);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (removed.has(at)) {
|
|
242
|
+
reject(pid, kind, ref, "an earlier pack already removed it");
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
if (!mayModify(pack.manifest, ownerOf(ref))) {
|
|
246
|
+
reject(
|
|
247
|
+
pid,
|
|
248
|
+
kind,
|
|
249
|
+
ref,
|
|
250
|
+
`${pid} does not declare ${ownerOf(ref)} as a dependency`,
|
|
251
|
+
);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return at;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
for (const pack of ordered) {
|
|
258
|
+
const contrib = pack.files[file];
|
|
259
|
+
if (!contrib) continue;
|
|
260
|
+
|
|
261
|
+
for (const [ref, body] of Object.entries(contrib.patches ?? {})) {
|
|
262
|
+
const at = resolve(pack, "patch", ref);
|
|
263
|
+
if (at === null) continue;
|
|
264
|
+
working[at] = mergePatch(working[at] as JsonRecord, body);
|
|
265
|
+
}
|
|
266
|
+
for (const [ref, body] of Object.entries(contrib.replaces ?? {})) {
|
|
267
|
+
const at = resolve(pack, "replace", ref);
|
|
268
|
+
if (at === null) continue;
|
|
269
|
+
working[at] = body;
|
|
270
|
+
}
|
|
271
|
+
for (const [ref, ops] of Object.entries(contrib.fieldPatches ?? {})) {
|
|
272
|
+
const at = resolve(pack, "fieldPatch", ref);
|
|
273
|
+
if (at === null) continue;
|
|
274
|
+
working[at] = applyFieldPatch(working[at] as JsonRecord, ops);
|
|
275
|
+
}
|
|
276
|
+
for (const ref of contrib.removes ?? []) {
|
|
277
|
+
const at = resolve(pack, "remove", ref);
|
|
278
|
+
if (at === null) continue;
|
|
279
|
+
removed.add(at);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const out: unknown[] = [];
|
|
284
|
+
working.forEach((r, i) => {
|
|
285
|
+
if (!removed.has(i)) out.push(r === null ? records[i] : r);
|
|
286
|
+
});
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Whether a passthrough file's records are name-keyed after all (mod-only file). */
|
|
291
|
+
function recordsKeyedByName(records: readonly unknown[]): boolean {
|
|
292
|
+
return records.some((r) => isNamedRecord(r));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Compose a set of loaded packs into merged per-file record arrays. With a
|
|
297
|
+
* single pack (the base game alone) the output is record-identical to the
|
|
298
|
+
* input: every record object is preserved by reference and its order is
|
|
299
|
+
* unchanged, so routing the base game through this path is a no-op.
|
|
300
|
+
*/
|
|
301
|
+
export function composeContentPacks(
|
|
302
|
+
packs: readonly LoadedPack[],
|
|
303
|
+
): ComposedContent {
|
|
304
|
+
const ordered = orderPacks(packs);
|
|
305
|
+
const problems: string[] = [];
|
|
306
|
+
|
|
307
|
+
const fileNames = new Set<string>();
|
|
308
|
+
for (const p of ordered) {
|
|
309
|
+
for (const f of Object.keys(p.files)) fileNames.add(f);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Classify each file: per-record composable, or whole-file passthrough.
|
|
313
|
+
const composable = new Set<string>();
|
|
314
|
+
for (const f of fileNames) {
|
|
315
|
+
let ok = true;
|
|
316
|
+
for (const p of ordered) {
|
|
317
|
+
const contrib = p.files[f];
|
|
318
|
+
if (contrib?.records && !recordsComposable(contrib.records)) {
|
|
319
|
+
ok = false;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (ok) composable.add(f);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const contents: PackContent[] = ordered.map((p) => {
|
|
327
|
+
const files: Record<string, FileContribution> = {};
|
|
328
|
+
for (const [f, contrib] of Object.entries(p.files)) {
|
|
329
|
+
if (composable.has(f)) files[f] = contrib;
|
|
330
|
+
}
|
|
331
|
+
return { manifest: p.manifest, files };
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const game = composePacks(contents);
|
|
335
|
+
|
|
336
|
+
const out: Record<string, unknown[]> = {};
|
|
337
|
+
for (const [file, table] of game) {
|
|
338
|
+
out[file] = [...table.values()].map((r) => r.value);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* Passthrough files, in two phases (see the header): the last provider in load
|
|
342
|
+
* order wins the whole file, then every pack's per-record ops apply on top. */
|
|
343
|
+
for (const f of fileNames) {
|
|
344
|
+
if (composable.has(f)) continue;
|
|
345
|
+
|
|
346
|
+
let providerId = "";
|
|
347
|
+
for (const p of ordered) {
|
|
348
|
+
const contrib = p.files[f];
|
|
349
|
+
if (!contrib?.records) continue;
|
|
350
|
+
if (providerId !== "") {
|
|
351
|
+
/* Whole-file replacement is destructive and used to be invisible: the
|
|
352
|
+
* previous provider's records simply vanished. Say so. */
|
|
353
|
+
problems.push(
|
|
354
|
+
`${p.manifest.id}: ${f} replaces the whole file, discarding ${(out[f] as unknown[]).length} record(s) from ${providerId} - ${f} records are not name-keyed, so a whole file is the only thing that can be added to it`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
out[f] = [...contrib.records];
|
|
358
|
+
providerId = p.manifest.id;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (providerId === "") {
|
|
362
|
+
/* SAFETY NET, not a live path. A file is only classified passthrough
|
|
363
|
+
* because some pack's `records` for it are not name-keyed, and such a pack
|
|
364
|
+
* is by definition a provider - so today providerId is never "" here. The
|
|
365
|
+
* equivalent case for a COMPOSABLE file (a mod patches a file nobody
|
|
366
|
+
* supplies records for) is loud already: composePacks throws
|
|
367
|
+
* "patch target ... does not exist". This branch is kept, cordoned and
|
|
368
|
+
* documented rather than deleted, because if the classification ever
|
|
369
|
+
* changes its absence would be a silent drop - the exact failure this file
|
|
370
|
+
* exists to prevent. Covered by loader.test.ts only through the composable
|
|
371
|
+
* counterpart. */
|
|
372
|
+
for (const p of ordered) {
|
|
373
|
+
const contrib = p.files[f];
|
|
374
|
+
if (!contrib) continue;
|
|
375
|
+
for (const { kind, ref } of perRecordOps(contrib)) {
|
|
376
|
+
problems.push(
|
|
377
|
+
`${p.manifest.id}: ${f} ${OP_VERB[kind]} "${ref}", but no pack supplies any ${f} records`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
out[f] = applyPassthroughOps(f, out[f] as unknown[], ordered, providerId, problems);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
records: out,
|
|
389
|
+
composedFiles: [...composable].sort(),
|
|
390
|
+
passthroughFiles: [...fileNames].filter((f) => !composable.has(f)).sort(),
|
|
391
|
+
problems,
|
|
392
|
+
};
|
|
393
|
+
}
|