@heroiclands/package-build 5.0.0 → 6.0.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.
@@ -78,7 +78,7 @@ import yargs from "yargs";
78
78
  import { hideBin } from "yargs/helpers";
79
79
 
80
80
  import { loadPackageBuildConfig } from "../config.mjs";
81
- import { loadPackConfig } from "../engine/pack-config.mjs";
81
+ import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
82
82
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
83
83
  import { validateLangSource } from "../lang.mjs";
84
84
  import {
@@ -354,6 +354,8 @@ function manifestCommand() {
354
354
  artifact: config.artifact,
355
355
  outDir: path.join(config.rootDir, config.stageDir),
356
356
  flags,
357
+ // So a `packFolders` finding names the line it is about.
358
+ configFile: packConfigPath(),
357
359
  });
358
360
  console.log(
359
361
  `✅ Wrote ${path.relative(config.rootDir, written)} ` +
@@ -34,9 +34,11 @@
34
34
  * | {@link BasePackCompiler#finish} | Work that needs every note first. |
35
35
  * | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
36
36
  *
37
- * plus two static switches — `requiresId` (a note with no id is fatal, or
38
- * merely skipped) and `convertsWikilinks` (whether the body reaching
39
- * `buildEntry` is converted or exactly as authored).
37
+ * plus three static switches — `requiresId` (a note with no id is fatal, or
38
+ * merely skipped), `convertsWikilinks` (whether the body reaching
39
+ * `buildEntry` is converted or exactly as authored) and `readsPackOutputOf`
40
+ * (the document types whose compiled output this pass reads, which is what the
41
+ * generator derives the compile order from).
40
42
  *
41
43
  * `selects` answers *which document type* a pass claims, and it is the same
42
44
  * answer for every pack of that type. Which **pack of that type** a claimed
@@ -141,6 +143,29 @@ export class BasePackCompiler {
141
143
  */
142
144
  static convertsWikilinks = true;
143
145
 
146
+ /**
147
+ * The document types whose **compiled output** this pass reads.
148
+ *
149
+ * Empty for every pass that reads only the content tree. The actors pass
150
+ * is the exception: a being names its embedded items by
151
+ * `(type, shortcode)`, and it resolves them against the JSON the item
152
+ * passes wrote — so an Actor pass must run after every Item pass, and it
153
+ * says so here.
154
+ *
155
+ * The generator derives the compile order from this (#73), so the order
156
+ * `packs:` declares is presentation only — it is the manifest's `packs`
157
+ * array as well, and a consumer orders that for a reader. A pass that
158
+ * reads another's output states the dependency once, in the class that
159
+ * does the reading, instead of every consuming repository having to know
160
+ * it when writing its pack list.
161
+ *
162
+ * A consumer registering a compiler of its own declares its dependencies
163
+ * the same way; a type no pack declares is simply not waited for.
164
+ *
165
+ * @type {readonly string[]}
166
+ */
167
+ static readsPackOutputOf = Object.freeze([]);
168
+
144
169
  /** @type {string} */
145
170
  contentBase;
146
171
  /** @type {string} */
@@ -61,6 +61,8 @@ import {
61
61
  readCanonicalKey,
62
62
  } from "./kb-manifest.mjs";
63
63
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
64
+ import { homepageAddresses, isHomepage } from "./homepage.mjs";
65
+ import { RETIRED_TYPES } from "./ids.mjs";
64
66
  import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
65
67
  import { readQualifier } from "./wikilinks.mjs";
66
68
 
@@ -305,6 +307,12 @@ export function buildLinkIndex(
305
307
  anchors,
306
308
  types,
307
309
  packages,
310
+ /**
311
+ * The one package this tree publishes. Distinct from `packages`, which
312
+ * is the set an address may name and which a homepage-only tree leaves
313
+ * this package out of, having no keyed note to put it there.
314
+ */
315
+ contentPackage: pkg,
308
316
  foreign,
309
317
  manifests: manifestsComplete(localPackages, foreign.packages),
310
318
  linksOf,
@@ -315,13 +323,223 @@ export function buildLinkIndex(
315
323
  };
316
324
  }
317
325
 
326
+ /**
327
+ * The site this project publishes on, as a host pattern.
328
+ *
329
+ * Hardcoded, as it is in {@link module:engine/homepage} already: every package's
330
+ * address is `https://www.heroiclands.org/<contentPackage>/`, and the whole
331
+ * point of the rule below is that an author *should not* be writing that host
332
+ * into a page. A configurable host would be a second place to write down the
333
+ * thing being discouraged.
334
+ *
335
+ * @type {RegExp}
336
+ */
337
+ const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
338
+
339
+ /**
340
+ * How an authored address resolves, or `null` for one nothing here can judge.
341
+ *
342
+ * Three shapes reach the site and one does not, and the distinction is the
343
+ * whole of what is checkable. An address into this site can be reasoned about
344
+ * from the package roster alone; an address to `github.com`, `kelestia.com` or
345
+ * `discord.gg` cannot be reasoned about at all without fetching it, and a build
346
+ * must not depend on a third party being up.
347
+ *
348
+ * @param {string} url - The authored address.
349
+ * @param {ReadonlySet<string>} packages - Package prefixes this build can name.
350
+ * @returns {{shape: string, segments: string[], prefix: string|null}|null} The
351
+ * shape, the path segments, and the package prefix the address starts with.
352
+ */
353
+ function readAddress(url, packages) {
354
+ const value = String(url ?? "").trim();
355
+ if (!value || value.startsWith("#")) return null;
356
+
357
+ let segments;
358
+ let shape;
359
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
360
+ let parsed;
361
+ try {
362
+ parsed = new URL(value);
363
+ } catch {
364
+ return null;
365
+ }
366
+ if (!/^https?:$/.test(parsed.protocol)) return null;
367
+ if (!SITE_HOST.test(parsed.hostname)) return null;
368
+ shape = "absolute";
369
+ segments = parsed.pathname.split("/").filter(Boolean);
370
+ } else if (value.startsWith("/")) {
371
+ shape = "rooted";
372
+ segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
373
+ } else {
374
+ shape = "relative";
375
+ segments = value.split("?")[0].split("#")[0].split("/").filter(Boolean);
376
+ }
377
+
378
+ const prefix =
379
+ shape !== "relative" && packages.has(segments[0]) ? segments[0] : null;
380
+ return { shape, segments, prefix };
381
+ }
382
+
383
+ /**
384
+ * Every defect in the addresses a package homepage carries.
385
+ *
386
+ * **Why the homepage needs its own audit at all.** Every other note addresses
387
+ * the corpus with wikilinks, which {@link auditLinks} resolves. A homepage does
388
+ * not and cannot: it is published *verbatim* by every publishing mode, including
389
+ * the homepage-only mode two fan-licensed packages ship under, where the content
390
+ * tree is never walked and there is no index for a wikilink to resolve against.
391
+ * So a landing addresses the web the way the web does — markdown links and
392
+ * `url:` fields — and nothing was looking at those. SoHL's landing pointed at
393
+ * `kb/creature/` and `kb/character/` from the day those types merged into
394
+ * `being`: two 404s on the package's front page, through every build.
395
+ *
396
+ * **What is checkable, stated plainly.** Only an address into this site is, and
397
+ * only against facts this build already holds:
398
+ *
399
+ * - A **retired content type** in the path. The engine knows what used to exist
400
+ * and what replaced it, so this is a fact rather than a guess — and it is
401
+ * exactly the SoHL defect.
402
+ * - A **hardcoded absolute URL** into this package's own prefix, or into one a
403
+ * vendored manifest names. Both have a better form to write, which is why they
404
+ * are reported; a bare `/<package>/` is left alone, because a package
405
+ * homepage is in no manifest and there is nothing better to write.
406
+ * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
407
+ * time. `href:` means "already resolved, use verbatim", so the same leading
408
+ * slash is correct there and is not reported.
409
+ * - A **wikilink**, which nothing on this page will ever resolve.
410
+ *
411
+ * **What is not checkable, and is not attempted.** Whether an external URL
412
+ * answers — there is no network at build time, and a build must not fail because
413
+ * a third party is down. And whether a live in-site address names a page that
414
+ * exists: several of the surfaces a landing routes to are produced by other
415
+ * tools entirely (generated API documentation, hand-authored Hugo sections), so
416
+ * this build does not hold the set of published pages and would report a working
417
+ * link as dead.
418
+ *
419
+ * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
420
+ * @returns {Array<{note: object, field: string, url: string, text: string,
421
+ * occurrence: number, message: string}>} One finding per defect, `text` and
422
+ * `occurrence` locating it in the note's raw source.
423
+ */
424
+ export function auditHomepageLinks(index) {
425
+ const findings = [];
426
+ const packages = new Set([index.contentPackage, ...index.packages]);
427
+
428
+ for (const note of index.notes) {
429
+ if (!isHomepage(note.fm)) continue;
430
+
431
+ // How many times each literal has been seen, so two identical
432
+ // addresses are located at their own positions.
433
+ const seen = new Map();
434
+ const at = (text) => {
435
+ const occurrence = (seen.get(text) ?? 0) + 1;
436
+ seen.set(text, occurrence);
437
+ return occurrence;
438
+ };
439
+ const report = (field, url, text, occurrence, message) =>
440
+ findings.push({ note, field, url, text, occurrence, message });
441
+
442
+ for (const [all, rawInner] of matchAllOutsideCode(
443
+ note.body,
444
+ new RegExp(WIKILINK.source, "g"),
445
+ )) {
446
+ const { target } = parseWikilink(rawInner);
447
+ report(
448
+ "body",
449
+ target,
450
+ all,
451
+ at(all),
452
+ `wikilink ${all} on the package homepage — a homepage is ` +
453
+ `published verbatim in every publishing mode, so nothing ` +
454
+ `resolves it; write a markdown link, package-relative`,
455
+ );
456
+ }
457
+
458
+ for (const { field, url, kind } of homepageAddresses(
459
+ note.fm,
460
+ note.body,
461
+ )) {
462
+ // Counted for every address, checked or not, so the count is
463
+ // the literal's nth appearance in the file rather than the nth
464
+ // *finding* about it — two rules can fire on one address.
465
+ const occurrence = at(url);
466
+ const address = readAddress(url, packages);
467
+ if (!address) continue;
468
+ const { shape, segments, prefix } = address;
469
+
470
+ if (shape === "absolute" && prefix) {
471
+ // A bare `/<package>/` is a package's homepage, which is in no
472
+ // link manifest and has no relative form from another package.
473
+ // A finding with no fix is noise.
474
+ const rest = segments.slice(1).join("/");
475
+ if (rest) {
476
+ report(
477
+ field,
478
+ url,
479
+ url,
480
+ occurrence,
481
+ prefix === index.contentPackage ?
482
+ `hardcoded absolute URL into this package's own ` +
483
+ `address — write the package-relative ` +
484
+ `"${rest}/", which the landing resolves ` +
485
+ `against the site so the page follows the mount`
486
+ : `hardcoded absolute URL into package "${prefix}" ` +
487
+ `— resolve it through that package's link ` +
488
+ `manifest, whose entries carry the address, so a ` +
489
+ `relocation does not leave this page behind`,
490
+ );
491
+ }
492
+ } else if (shape === "rooted" && kind === "url") {
493
+ const rest =
494
+ prefix ? segments.slice(1).join("/") : segments.join("/");
495
+ report(
496
+ field,
497
+ url,
498
+ url,
499
+ occurrence,
500
+ `url "${url}" is root-relative, but a landing's url: is ` +
501
+ `resolved against the site — write "${rest}/", or ` +
502
+ `href: for an address that is already resolved`,
503
+ );
504
+ }
505
+
506
+ // The retired-type rule reads the path *inside* the package, so an
507
+ // address that named one is fixed the same way wherever it was
508
+ // written.
509
+ const inPackage = prefix ? segments.slice(1) : segments;
510
+ for (const [i, segment] of inPackage.entries()) {
511
+ // `hasOwn`, not a plain lookup: a path segment spelled
512
+ // `constructor` would otherwise inherit a truthy answer from
513
+ // `Object.prototype` and be reported as retired.
514
+ if (!Object.hasOwn(RETIRED_TYPES, segment)) continue;
515
+ const replacement = RETIRED_TYPES[segment];
516
+ const fixed = [...inPackage];
517
+ fixed[i] = replacement;
518
+ report(
519
+ field,
520
+ url,
521
+ url,
522
+ occurrence,
523
+ `address "${url}" names content type "${segment}", ` +
524
+ `retired in favour of "${replacement}" — both ` +
525
+ `compiled to the same document, so the fix is ` +
526
+ `mechanical: "${fixed.join("/")}/"`,
527
+ );
528
+ }
529
+ }
530
+ }
531
+
532
+ return findings;
533
+ }
534
+
318
535
  /**
319
536
  * Every link in a tree that lands nowhere.
320
537
  *
321
538
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
322
539
  * @returns {{deadAnchors: object[], deadAddresses: object[],
323
- * frontmatterLinks: object[], usedManifest: Set<string>}} The findings, and
324
- * which addresses a foreign manifest answered.
540
+ * frontmatterLinks: object[], homepageLinks: object[],
541
+ * usedManifest: Set<string>}} The findings, and which addresses a foreign
542
+ * manifest answered.
325
543
  */
326
544
  export function auditLinks(index) {
327
545
  const { notes, anchors, linksOf, resolve, manifestHit, isAddress } = index;
@@ -370,6 +588,7 @@ export function auditLinks(index) {
370
588
  deadAnchors,
371
589
  deadAddresses,
372
590
  frontmatterLinks: index.frontmatterLinks,
591
+ homepageLinks: auditHomepageLinks(index),
373
592
  usedManifest,
374
593
  };
375
594
  }
@@ -164,15 +164,24 @@ export function lintContentTree(contentBase, { skipDirectories } = {}) {
164
164
  // "Every one of nothing is unique" is a vacuous pass, and it is exactly
165
165
  // what a tree that failed to check out produces — so the lint would go
166
166
  // green on the one state it most needs to catch.
167
- if (byKey.size === 0) {
167
+ //
168
+ // The state that catches is an **empty walk**, not an empty key set (#77).
169
+ // A note may be keyless by design: a homepage carries no `shortcode`
170
+ // because it is addressed by the package rather than by a slug, so a
171
+ // package in `publish.site: homepage` mode has a content tree that is
172
+ // populated, correct, and permanently unkeyed. Reporting that as a missing
173
+ // checkout trains its author to stop reading the output — the one thing
174
+ // this guard needs them to do. A tree holding notes is therefore a tree;
175
+ // only a tree holding none is the absent one.
176
+ if (notes.length === 0) {
168
177
  findings.push({
169
178
  file: path.relative(process.cwd(), contentBase) || contentBase,
170
179
  severity: "error",
171
180
  message:
172
- "holds no keyed content, so every rule here is vacuous — " +
181
+ "holds no content notes, so every rule here is vacuous — " +
173
182
  "check that the content tree is present and that this is its root",
174
183
  });
175
- return { findings, notes: notes.length, keys: 0 };
184
+ return { findings, notes: 0, keys: 0 };
176
185
  }
177
186
 
178
187
  for (const [key, files] of byKey) {
@@ -44,6 +44,7 @@
44
44
  */
45
45
 
46
46
  import path from "node:path";
47
+ import YAML, { LineCounter } from "yaml";
47
48
 
48
49
  /**
49
50
  * The `file:line:column` locator, with whatever is known.
@@ -268,3 +269,48 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
268
269
  column: at - before.lastIndexOf("\n"),
269
270
  };
270
271
  }
272
+
273
+ /**
274
+ * Where a **key or value in a YAML document** sits, addressed by its path.
275
+ *
276
+ * {@link positionOfLiteral} is the plain search, and it is the wrong tool for a
277
+ * configuration file: a pack name like `items` or `actors` appears in the
278
+ * `packs:` block, in a folder's list, in a path, and often in prose, so the
279
+ * first occurrence is routinely not the one the finding is about — which is the
280
+ * one thing the located form exists to prevent.
281
+ *
282
+ * A path resolves the exact node instead. The document is re-parsed here rather
283
+ * than threaded from the loader because the loader returns plain data: `yaml`
284
+ * discards ranges once a document is materialised, and carrying a parallel
285
+ * position tree through configuration resolution would be a second
286
+ * representation of the same file to keep in step.
287
+ *
288
+ * Every failure — unparseable text, an `.mjs` configuration, a path that
289
+ * resolves to nothing — yields `{}`, so a caller spreads the result and the
290
+ * position is dropped rather than guessed.
291
+ *
292
+ * @param {string} text - The document's contents.
293
+ * @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
294
+ * strings, sequence entries as numbers.
295
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
296
+ */
297
+ export function positionOfYamlPath(text, keyPath) {
298
+ if (typeof text !== "string" || !text) return {};
299
+ if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
300
+
301
+ let node;
302
+ const counter = new LineCounter();
303
+ try {
304
+ const doc = YAML.parseDocument(text, { lineCounter: counter });
305
+ // `keepScalar` returns the Scalar node rather than its value, which is
306
+ // the only form carrying a range.
307
+ node = doc.getIn(keyPath, true);
308
+ } catch {
309
+ return {};
310
+ }
311
+
312
+ const start = node?.range?.[0];
313
+ if (!Number.isFinite(start)) return {};
314
+ const { line, col } = counter.linePos(start);
315
+ return { line, column: col };
316
+ }
@@ -23,10 +23,15 @@
23
23
  * Each `*` compiler walks the whole content tree and selects its own entries by
24
24
  * the note's `type` — every note in the tree belongs to this repository's
25
25
  * `contentPackage` (#56) — so routing is directory-agnostic: a file lands in a
26
- * pack because of its `type`, not its location. Which packs exist, in what order, and which folder
27
- * hierarchy each one loads are all declared in `package-build.config.yaml`;
28
- * folder files live under the content root and are referenced from entry
29
- * frontmatter via `sohl.folder: <id>`.
26
+ * pack because of its `type`, not its location. Which packs exist and which
27
+ * folder hierarchy each one loads are declared in
28
+ * `package-build.config.yaml`; folder files live under the content root and are
29
+ * referenced from entry frontmatter via `sohl.folder: <id>`.
30
+ *
31
+ * **The order the passes run in is derived, not declared** — see
32
+ * {@link orderPassesByDependency}. The declared list is the manifest's `packs`
33
+ * array as well, so it is ordered for a reader; a pass that reads another's
34
+ * output states that on its compiler and is scheduled after it (#73).
30
35
  *
31
36
  * This replaces the retired `packs:export` (vault → committed `_source/`); the
32
37
  * HeroicLands vault is no longer a build input for SoHL content.
@@ -50,6 +55,7 @@ import {
50
55
  writeFolderDocs,
51
56
  } from "./helpers.mjs";
52
57
  import { countContentNotes } from "./content-tree.mjs";
58
+ import { emitDiagnostic } from "./diagnostics.mjs";
53
59
  import { loadPackConfig } from "./pack-config.mjs";
54
60
  import { routerFor } from "./pack-router.mjs";
55
61
 
@@ -89,8 +95,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
89
95
  * and an actor's embedded items may be sourced from any of them. Finding one
90
96
  * pack and stopping is how embedded-item resolution would silently miss every
91
97
  * item that landed in another. Returned in configured order, which is also the
92
- * order they compile in, so a pack later in the list cannot be read before it
93
- * is written.
98
+ * order they compile in {@link orderPassesByDependency} keeps the declared
99
+ * order among packs of one type — and every one of them is written before the
100
+ * actors pass that reads them.
94
101
  *
95
102
  * @param {object} [config] - The resolved build configuration. Defaults to this
96
103
  * repository's.
@@ -104,6 +111,126 @@ export function itemPackJsonDirs(config = loadPackConfig()) {
104
111
  .map((pack) => packJsonDir(pack.name, config));
105
112
  }
106
113
 
114
+ /**
115
+ * The document types whose compiled output a pass of this type reads.
116
+ *
117
+ * Asked of the compiler rather than held in a table here, so the dependency
118
+ * lives in the class that does the reading and a consumer's own compiler can
119
+ * declare its own. A type no compiler is registered for waits on nothing —
120
+ * {@link generatePack} reports it as an unknown type, which is the better
121
+ * message.
122
+ *
123
+ * @param {string} type - A pack's document type.
124
+ * @returns {readonly string[]} The types it reads the output of.
125
+ */
126
+ function readsOutputOf(type) {
127
+ return COMPILERS[type]?.readsPackOutputOf ?? [];
128
+ }
129
+
130
+ /**
131
+ * The passes to run, ordered so that each one follows the output it reads.
132
+ *
133
+ * **Declaration order is presentation, not compile order (#73).** The same
134
+ * `packs:` list is the manifest's `packs` array, which a consumer orders for a
135
+ * reader browsing compendiums; the actors pass, meanwhile, resolves each
136
+ * being's embedded items against the item passes' *output*. Making one list
137
+ * satisfy both meant an Actor pack declared first compiled only where a
138
+ * previous run had already left `build/packs-json` populated — green on a warm
139
+ * tree, exit 1 on every fresh checkout and every CI runner, and `build/` is
140
+ * gitignored so that is the state CI always starts from.
141
+ *
142
+ * **The reordering is the smallest one that works.** Each step takes the
143
+ * *earliest declared* pass whose dependencies are all already emitted, so a
144
+ * list that was already in a workable order comes back untouched, and one that
145
+ * was not moves exactly the passes that had to move. A dependency is satisfied
146
+ * only when **every** pack of that type has run: a being addresses an item by
147
+ * `(type, shortcode)` without knowing which Item pack ships it, so waiting for
148
+ * one of several would resolve some beings and silently fail others.
149
+ *
150
+ * A dependency on a type this configuration declares no pack of is not waited
151
+ * for. A package may ship an Actor pack and no Item pack; the pass that needs
152
+ * one refuses on its own, with a message about items rather than about order.
153
+ *
154
+ * @param {readonly object[]} packs - The passes to be run, as declared.
155
+ * @returns {object[]} A new list, in compile order. The input is untouched.
156
+ * @throws {Error} If the passes read each other's output in a cycle, which no
157
+ * order can satisfy. Only reachable from a mis-declared compiler, so it names
158
+ * the passes rather than blaming the pack list.
159
+ */
160
+ export function orderPassesByDependency(packs) {
161
+ const declared = new Set(packs.map((pack) => pack.type));
162
+ const remaining = [...packs];
163
+ const emitted = new Set();
164
+ /** @type {object[]} */
165
+ const ordered = [];
166
+
167
+ /** Whether every pack this pass reads the output of has already run. */
168
+ const ready = (pack) =>
169
+ readsOutputOf(pack.type).every(
170
+ (dependency) =>
171
+ !declared.has(dependency) ||
172
+ packs.every(
173
+ (other) =>
174
+ other.type !== dependency || emitted.has(other.name),
175
+ ),
176
+ );
177
+
178
+ while (remaining.length) {
179
+ const next = remaining.findIndex(ready);
180
+ if (next === -1) {
181
+ throw new Error(
182
+ `package-build: the configured passes read each other's ` +
183
+ `output in a cycle (` +
184
+ `${remaining.map((pack) => `${pack.name} (${pack.type})`).join(", ")}` +
185
+ `); no compile order can satisfy that.`,
186
+ );
187
+ }
188
+ const [pack] = remaining.splice(next, 1);
189
+ emitted.add(pack.name);
190
+ ordered.push(pack);
191
+ }
192
+ return ordered;
193
+ }
194
+
195
+ /**
196
+ * The dependencies this run cannot satisfy by ordering, because the pass that
197
+ * would produce them is not in it.
198
+ *
199
+ * Ordering answers the whole-package build; a run restricted to one pack
200
+ * (`content-build package compile <name>`) cannot conjure the passes it left
201
+ * out. Where their output is already on disk from an earlier run that is fine
202
+ * — it is how compiling one pack at a time is meant to work — so this reports
203
+ * only what is genuinely absent, and names the pack that would write it rather
204
+ * than the directory that is missing.
205
+ *
206
+ * @param {readonly object[]} running - The passes this run will execute.
207
+ * @param {object} config - The resolved build configuration.
208
+ * @returns {string[]} One message per unsatisfiable dependency.
209
+ */
210
+ export function unsatisfiedPassDependencies(running, config) {
211
+ const included = new Set(running.map((pack) => pack.name));
212
+ /** @type {string[]} */
213
+ const messages = [];
214
+ for (const pack of running) {
215
+ for (const dependency of readsOutputOf(pack.type)) {
216
+ for (const producer of config.packs) {
217
+ if (producer.type !== dependency) continue;
218
+ if (included.has(producer.name)) continue;
219
+ const dir = packJsonDir(producer.name, config);
220
+ if (fs.existsSync(dir)) continue;
221
+ messages.push(
222
+ `pack "${pack.name}" (${pack.type}) reads the compiled ` +
223
+ `output of the ${producer.type} pack ` +
224
+ `"${producer.name}", which this run does not compile ` +
225
+ `and which ${dir} does not hold — compile the whole ` +
226
+ `package, or compile "${producer.name}" first`,
227
+ );
228
+ }
229
+ }
230
+ }
231
+ return messages;
232
+ }
233
+
107
234
  /**
108
235
  * Generate the per-entry JSON for one pack into `build/packs-json/<name>/`.
109
236
  *
@@ -280,9 +407,31 @@ export async function generatePacksJson({
280
407
  if (!firstOfType.has(pack.type)) firstOfType.set(pack.type, pack.name);
281
408
  }
282
409
 
410
+ // Compile order is derived from what each pass reads, not from the order
411
+ // `packs:` declares — that list is also the manifest's, which a consumer
412
+ // orders for a reader (#73).
413
+ const ordered = orderPassesByDependency(packs);
414
+ if (ordered.some((pack, index) => pack !== packs[index])) {
415
+ log.info(
416
+ `Pass order: ${ordered.map((pack) => pack.name).join(", ")} — a ` +
417
+ `pass that reads another's output compiles after it, whatever ` +
418
+ `order \`packs:\` declares.`,
419
+ );
420
+ }
421
+
422
+ // What ordering cannot reach: a run restricted to one pack, whose
423
+ // dependencies are simply not in it and not on disk either.
424
+ const unsatisfied = unsatisfiedPassDependencies(ordered, config);
425
+ if (unsatisfied.length) {
426
+ for (const message of unsatisfied) {
427
+ emitDiagnostic({ severity: "error", message });
428
+ }
429
+ return unsatisfied.length;
430
+ }
431
+
283
432
  let totalErrors = 0;
284
433
  const passes = [];
285
- for (const pack of packs) {
434
+ for (const pack of ordered) {
286
435
  const { errors, compiled } = await generatePack(
287
436
  pack,
288
437
  config,