@heroiclands/package-build 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c2fc37: **`package-build bundle check` — the last capability that had no command.**
8
+
9
+ The bundle-loading check was exported as a library function and reachable no
10
+ other way, so a consumer that wanted it had to write the script the command line
11
+ exists to remove: read the manifest, read the bundle, call the function, decide
12
+ how to print findings, choose an exit code. It now runs from configuration like
13
+ every other job.
14
+
15
+ It catches three ways a package builds successfully and still does not load,
16
+ none of which a bundler can see, because each is a disagreement between two
17
+ files rather than a fault in either:
18
+
19
+ | The manifest says | What Foundry does |
20
+ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
21
+ | the entry under both `esmodules` and `scripts` | loads the bundle twice |
22
+ | the entry under neither | never loads it at all |
23
+ | the entry under `esmodules`, but the file only parses as a classic script | fails at load, naming whichever `import` came first and nothing about the manifest |
24
+
25
+ Both files are read from the stage, because the stage is what ships.
26
+
27
+ **The entry is derived, not stated.** `packageBuild.bundle.entry` defaults to
28
+ `<packageId>.mjs`, which is already derived from `package.json` `name`; a
29
+ repository states it only when its bundler emits something else. It is
30
+ deliberately _not_ read back out of the generated manifest — a value taken from
31
+ there would agree with itself by construction, and the check's whole question is
32
+ whether the manifest declares this file the way Foundry needs it.
33
+
34
+ **Reporting is now decided once.** `lang check` had the diagnostic contract —
35
+ `file:line:column: severity: message`, the path starting the line, a field
36
+ dropped rather than guessed — spelled out inline in its handler. Both commands
37
+ now report through one seam that maps findings onto the format
38
+ `@heroiclands/content-build` already owns, so the two packages cannot drift into
39
+ two nearly-identical formats. `lang check`'s output is unchanged.
40
+
41
+ **The command surface has a stated shape.** A capability with a single operation
42
+ is a bare command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`);
43
+ one with more than a single operation takes a positional action, so a second can
44
+ be added without renaming the first (`lang check`, `bundle check`).
45
+
46
+ Closes #12
47
+
3
48
  ## 0.3.0
4
49
 
5
50
  ### Minor Changes
package/README.md CHANGED
@@ -96,6 +96,14 @@ packageBuild:
96
96
  # Prefix of the shared SFTP override variables. Default `SOHL`.
97
97
  envPrefix: SOHL
98
98
 
99
+ bundle:
100
+ # The file Foundry loads, as the manifest spells it, relative to the stage.
101
+ # Defaults to `<packageId>.mjs`; state it only when the bundler emits
102
+ # something else. Deliberately not read back out of the manifest — a value
103
+ # taken from there would agree with itself by construction, and `bundle
104
+ # check` asks whether the manifest declares this file correctly.
105
+ entry: sohl.mjs
106
+
99
107
  # Optional. A module exporting `flags(config)` returning namespaced Foundry
100
108
  # flags the repository has to *compute* — an address that only exists once
101
109
  # the content tree has been walked, say. Merged over any declared below.
@@ -176,11 +184,12 @@ other's schema — they split by input, and the dependency runs one way.
176
184
 
177
185
  **What is derived, not stated:**
178
186
 
179
- | Field | Derived from |
180
- | -------------------------- | -------------------------------------------------------------------- |
181
- | the repository root | the configuration file's own location |
182
- | `packageKind`, `packageId` | the shared configuration's top level |
183
- | the release artifact | `packageKind` — a system ships `system.json`, a module `module.json` |
187
+ | Field | Derived from |
188
+ | -------------------------- | --------------------------------------------------------------------------- |
189
+ | the repository root | the configuration file's own location |
190
+ | `packageKind`, `packageId` | the shared configuration's top level |
191
+ | the release artifact | `packageKind` — a system ships `system.json`, a module `module.json` |
192
+ | the bundle entry | `packageId` — `<id>.mjs`, unless `packageBuild.bundle.entry` says otherwise |
184
193
 
185
194
  ## Command line
186
195
 
@@ -189,6 +198,7 @@ npx package-build clean [--distclean]
189
198
  npx package-build assets
190
199
  npx package-build manifest
191
200
  npx package-build lang check
201
+ npx package-build bundle check
192
202
  npx package-build release
193
203
  npx package-build deploy <stage>
194
204
  ```
@@ -201,11 +211,34 @@ Wrapped as npm scripts — SoHL spells them:
201
211
  "distclean": "package-build clean --distclean",
202
212
  "build:assets": "package-build assets",
203
213
  "lint:lang": "package-build lang check",
214
+ "lint:bundle-globals": "package-build bundle check",
204
215
  "build:pack-release": "package-build release",
205
216
  "push:qa": "package-build deploy qa"
206
217
  }
207
218
  ```
208
219
 
220
+ **The shape of the surface.** A capability with a single operation is a bare
221
+ command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`); one with
222
+ more than a single operation takes a positional action, so a second can be added
223
+ without renaming the first (`lang check`, `bundle check`). Flat `lang:check`
224
+ names would make every operation a new top-level command and hide which ones
225
+ belong together.
226
+
227
+ **What `bundle check` checks.** Three ways a package builds successfully and
228
+ still does not load, none of which a bundler can see, because each is a
229
+ disagreement between two files rather than a fault in either:
230
+
231
+ | The manifest says | What Foundry does |
232
+ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
233
+ | the entry under both `esmodules` and `scripts` | loads the bundle twice |
234
+ | the entry under neither | never loads it at all |
235
+ | the entry under `esmodules`, but the file only parses as a classic script | fails at load, naming whichever `import` came first and nothing about the manifest |
236
+
237
+ It reads both files from the stage, because the stage is what ships — checking
238
+ sources would answer for a package nobody installs. Findings are emitted as
239
+ `file:line:column: severity: message`, and the command exits non-zero when any
240
+ of them is an error.
241
+
209
242
  **Why the CLI exists.** This package was library-only, so every consuming
210
243
  repository wrote a wrapper script per job — six of them in the SoHL repository,
211
244
  441 lines that between them contained no logic. `clean.mjs` was 47 lines that
@@ -34,6 +34,13 @@
34
34
  * stay import-safe, so a consuming repository's build — or a test — can call
35
35
  * them without any of it happening.
36
36
  *
37
+ * **The shape of the command surface.** A capability with a single operation is
38
+ * a bare command (`clean`, `assets`, `manifest`, `release`, `deploy <stage>`);
39
+ * one with more than a single operation takes a positional action, so it can
40
+ * grow another without renaming the first (`lang check`, `bundle check`). The
41
+ * alternative — flat `lang:check`-style names — makes the second operation a
42
+ * new top-level command and the relationship between them invisible.
43
+ *
37
44
  * The side effects that need *configuration* live inside the command handlers,
38
45
  * never at module scope, so `--version` and `--help` answer in a directory with
39
46
  * no configuration at all. Running an actual command still resolves it, and
@@ -44,6 +51,7 @@
44
51
  * npx package-build assets
45
52
  * npx package-build manifest
46
53
  * npx package-build lang check
54
+ * npx package-build bundle check
47
55
  * npx package-build release
48
56
  * npx package-build deploy <stage>
49
57
  *
@@ -51,6 +59,7 @@
51
59
  * npm run clean // → … clean
52
60
  * npm run build:assets // → … assets
53
61
  * npm run lint:lang // → … lang check
62
+ * npm run lint:bundle-globals // → … bundle check
54
63
  * npm run build:pack-release // → … release
55
64
  * npm run push:qa // → … deploy qa
56
65
  */
@@ -66,9 +75,11 @@ import { loadPackageBuildConfig } from "../config.mjs";
66
75
  import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
67
76
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
68
77
  import { validateLangSource } from "../lang.mjs";
78
+ import { checkBundleLoading } from "../bundle.mjs";
69
79
  import { packRelease } from "../release.mjs";
70
80
  import { writeManifest } from "../manifest.mjs";
71
81
  import { deployStage } from "../deploy.mjs";
82
+ import { reportFindings } from "./report.mjs";
72
83
 
73
84
  /**
74
85
  * This package's own version, for `--version`.
@@ -312,20 +323,10 @@ function langCommand() {
312
323
 
313
324
  let total = 0;
314
325
  for (const file of files.sort()) {
315
- const relative = path.relative(config.rootDir, file);
316
- for (const finding of validateLangSource(
317
- fs.readFileSync(file, "utf8"),
318
- )) {
319
- total++;
320
- // The diagnostics contract: the path starts the line, and a
321
- // field is dropped rather than guessed.
322
- const at = [relative, finding.line, finding.column]
323
- .filter((part) => part !== undefined && part !== null)
324
- .join(":");
325
- console.error(
326
- `${at}: ${finding.severity ?? "error"}: ${finding.message}`,
327
- );
328
- }
326
+ total += reportFindings(
327
+ validateLangSource(fs.readFileSync(file, "utf8")),
328
+ { file: path.relative(config.rootDir, file) },
329
+ );
329
330
  }
330
331
 
331
332
  if (total) {
@@ -340,6 +341,72 @@ function langCommand() {
340
341
  };
341
342
  }
342
343
 
344
+ /**
345
+ * `bundle check` — does the manifest agree with the bundle it points at?
346
+ *
347
+ * Three ways a package can be built successfully and still not load, none of
348
+ * which the bundler can see because each is a disagreement between two files:
349
+ * the entry listed under both `esmodules` and `scripts` (Foundry loads it
350
+ * twice), under neither (Foundry never loads it), or under `esmodules` while
351
+ * the emitted file only parses as a classic script. The last fails at runtime
352
+ * with a message about whichever `import` came first and says nothing about the
353
+ * manifest, which is the kind of error that costs an afternoon.
354
+ *
355
+ * Both files are read from the stage, because the stage is what ships. Checking
356
+ * sources would answer for a package nobody installs.
357
+ *
358
+ * @returns {object} The yargs command module.
359
+ */
360
+ function bundleCommand() {
361
+ return {
362
+ command: "bundle <action>",
363
+ describe: "Code-bundle checks",
364
+ builder: (y) =>
365
+ y.positional("action", {
366
+ choices: ["check"],
367
+ describe:
368
+ "check: verify the manifest and the staged bundle agree",
369
+ }),
370
+ handler: handler(() => {
371
+ const config = loadPackageBuildConfig();
372
+ const stageDir = path.join(config.rootDir, config.stageDir);
373
+ const manifestPath = path.join(stageDir, `${config.artifact}.json`);
374
+ const bundlePath = path.join(stageDir, config.bundleEntry);
375
+
376
+ // Named explicitly rather than left to a missing-file stack trace:
377
+ // both absences mean "the stage was not built", and a reader who
378
+ // sees the path knows which step to run.
379
+ for (const [what, where] of [
380
+ ["manifest", manifestPath],
381
+ ["bundle", bundlePath],
382
+ ]) {
383
+ if (!fs.existsSync(where)) {
384
+ die(
385
+ `no staged ${what} at ` +
386
+ `${path.relative(config.rootDir, where)} — build ` +
387
+ `the stage before checking it.`,
388
+ );
389
+ }
390
+ }
391
+
392
+ const relative = path.relative(config.rootDir, bundlePath);
393
+ const { findings, declaredAs } = checkBundleLoading({
394
+ manifest: JSON.parse(fs.readFileSync(manifestPath, "utf8")),
395
+ source: fs.readFileSync(bundlePath, "utf8"),
396
+ entry: config.bundleEntry,
397
+ manifestName: path.relative(config.rootDir, manifestPath),
398
+ });
399
+
400
+ if (reportFindings(findings, { file: relative })) process.exit(1);
401
+
402
+ console.log(
403
+ `package-build: ${config.bundleEntry} is declared under ` +
404
+ `"${declaredAs}" and loads as one.`,
405
+ );
406
+ }),
407
+ };
408
+ }
409
+
343
410
  /**
344
411
  * `release` — zip the staged package for a GitHub release.
345
412
  *
@@ -421,6 +488,7 @@ yargs(hideBin(process.argv))
421
488
  .command(assetsCommand())
422
489
  .command(manifestCommand())
423
490
  .command(langCommand())
491
+ .command(bundleCommand())
424
492
  .command(releaseCommand())
425
493
  .command(deployCommand())
426
494
  .demandCommand(1, "Name a command.")
package/bin/report.mjs ADDED
@@ -0,0 +1,108 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * How the command line reports what a rule found.
16
+ *
17
+ * The package's rules are pure: they take source text and return findings,
18
+ * leaving discovery, I/O and reporting to whoever called them. That is what
19
+ * makes them testable, but it also means every caller has to decide the same
20
+ * two things — what a finding looks like once emitted, and what a run's exit
21
+ * code should be. Before there was a command line, every consumer decided them
22
+ * separately, and no two agreed.
23
+ *
24
+ * This module is where the binary decides them once. It lives under `bin/`
25
+ * rather than beside the rules deliberately: it is the *caller's* half, not
26
+ * part of the pure surface, and nothing importing this package as a library
27
+ * should reach it.
28
+ *
29
+ * The emitted form is the toolchain's diagnostic contract —
30
+ * `file:line:column: severity: message`, the path starting the line, a field
31
+ * dropped rather than guessed — which `@heroiclands/content-build` already
32
+ * owns. This module maps findings onto it rather than restating it, so the two
33
+ * packages cannot drift into two nearly-identical formats.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { emitDiagnostic } from "@heroiclands/content-build/engine/diagnostics";
39
+
40
+ /**
41
+ * A finding as the pure rules report one.
42
+ *
43
+ * @typedef {object} Finding
44
+ * @property {string} message What is wrong, in one sentence.
45
+ * @property {"warning"|"error"} [severity] Defaults to `error`.
46
+ * @property {number} [line] 1-based line, when known.
47
+ * @property {number} [column] 1-based column, when known.
48
+ */
49
+
50
+ /**
51
+ * Turn a rule's findings into diagnostics, attaching the file they are about.
52
+ *
53
+ * A rule is handed source text and never learns the path it came from, so the
54
+ * caller is the only one who can say it — and a finding without a file is one
55
+ * nothing can navigate to.
56
+ *
57
+ * **A field is dropped, never guessed.** A finding that knows no line emits as
58
+ * `file: severity: message`, because defaulting to `1:1` sends a reader to the
59
+ * top of the file every time and reads exactly like a real position. A column
60
+ * without a line is dropped for the same reason: it locates nothing on its own.
61
+ *
62
+ * @param {Finding[]} findings - What the rule returned.
63
+ * @param {object} opts
64
+ * @param {string} opts.file - Path to the file, relative to the working
65
+ * directory, so the emitted line is one an editor or `cc`-style parser can
66
+ * open.
67
+ * @returns {Array<{file: string, line?: number, column?: number,
68
+ * severity: "warning"|"error", message: string}>} The diagnostics, in the
69
+ * order the rule reported them.
70
+ */
71
+ export function toDiagnostics(findings, { file }) {
72
+ return findings.map((finding) => {
73
+ const hasLine = finding.line !== undefined && finding.line !== null;
74
+ const hasColumn =
75
+ finding.column !== undefined && finding.column !== null;
76
+ return {
77
+ file,
78
+ ...(hasLine ? { line: finding.line } : {}),
79
+ // Only alongside a line: `formatLocator` ignores a lone column, and
80
+ // carrying it anyway would invite a reader to trust it.
81
+ ...(hasLine && hasColumn ? { column: finding.column } : {}),
82
+ severity: finding.severity ?? "error",
83
+ message: finding.message,
84
+ };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Emit a rule's findings and say how many of them fail the run.
90
+ *
91
+ * Warnings are emitted and counted separately from the return value, so a
92
+ * command can report something worth reading without failing a build over it.
93
+ *
94
+ * @param {Finding[]} findings - What the rule returned.
95
+ * @param {object} opts
96
+ * @param {string} opts.file - Path to the file, relative to the working dir.
97
+ * @param {(d: object) => void} [opts.emit] - How to emit one diagnostic.
98
+ * Injectable so a test can capture the emitted shape without reaching for
99
+ * the console; defaults to content-build's `emitDiagnostic`, which writes
100
+ * both severities to stderr.
101
+ * @returns {number} How many diagnostics were errors — the count a command
102
+ * turns into its exit code.
103
+ */
104
+ export function reportFindings(findings, { file, emit = emitDiagnostic }) {
105
+ const diagnostics = toDiagnostics(findings, { file });
106
+ for (const diagnostic of diagnostics) emit(diagnostic);
107
+ return diagnostics.filter((d) => d.severity === "error").length;
108
+ }
package/config.mjs CHANGED
@@ -69,6 +69,7 @@ const SECTION_KEYS = [
69
69
  "lang",
70
70
  "deploy",
71
71
  "release",
72
+ "bundle",
72
73
  ];
73
74
 
74
75
  /**
@@ -98,6 +99,7 @@ const CLEAN_KEYS = ["extra"];
98
99
  const LANG_KEYS = ["sources", "help"];
99
100
  const DEPLOY_KEYS = ["envPrefix"];
100
101
  const RELEASE_KEYS = ["artifact"];
102
+ const BUNDLE_KEYS = ["entry"];
101
103
 
102
104
  /**
103
105
  * The artifact name each package kind ships, so no repository states it.
@@ -240,6 +242,8 @@ function normalizeManifest(value) {
240
242
  * @property {string} langSources Glob for the localization files to check.
241
243
  * @property {string|null} langHelp Extra guidance printed after a failure.
242
244
  * @property {string} envPrefix Prefix of the deploy environment variables.
245
+ * @property {string} bundleEntry The bundle file Foundry loads, as the
246
+ * manifest spells it. Derived from the package id.
243
247
  */
244
248
 
245
249
  /**
@@ -308,6 +312,15 @@ export function resolvePackageBuildConfig(shared) {
308
312
  );
309
313
  const releaseInput = /** @type {Record<string, unknown>} */ (release);
310
314
 
315
+ const bundle = section.bundle ?? {};
316
+ if (!isMapping(bundle)) fail("packageBuild.bundle", "must be a mapping");
317
+ rejectUnknownKeys(
318
+ /** @type {Record<string, unknown>} */ (bundle),
319
+ BUNDLE_KEYS,
320
+ "packageBuild.bundle.",
321
+ );
322
+ const bundleInput = /** @type {Record<string, unknown>} */ (bundle);
323
+
311
324
  return Object.freeze({
312
325
  rootDir: shared.rootDir,
313
326
  // Where the package is assembled before it is zipped or deployed. Every
@@ -375,6 +388,22 @@ export function resolvePackageBuildConfig(shared) {
375
388
  deployInput.envPrefix,
376
389
  "packageBuild.deploy.envPrefix",
377
390
  ),
391
+ // The file Foundry loads, as the manifest spells it. Named after the
392
+ // package by convention, and the id is already derived from
393
+ // package.json `name` — so a repository states this only when its
394
+ // bundler emits something else.
395
+ //
396
+ // Deliberately *not* read back out of the generated manifest: the
397
+ // check's question is whether the manifest declares this file the way
398
+ // Foundry needs it, and a value taken from the manifest could never
399
+ // answer that — it would agree with itself by construction.
400
+ bundleEntry:
401
+ bundleInput.entry === undefined ?
402
+ `${shared.foundryPackage}.mjs`
403
+ : requireNonEmptyString(
404
+ bundleInput.entry,
405
+ "packageBuild.bundle.entry",
406
+ ),
378
407
  });
379
408
  }
380
409
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -22,6 +22,8 @@
22
22
  * @property {string} langSources Glob for the localization files to check.
23
23
  * @property {string|null} langHelp Extra guidance printed after a failure.
24
24
  * @property {string} envPrefix Prefix of the deploy environment variables.
25
+ * @property {string} bundleEntry The bundle file Foundry loads, as the
26
+ * manifest spells it. Derived from the package id.
25
27
  */
26
28
  /**
27
29
  * Resolve a package-build configuration from an already-loaded shared one.
@@ -135,4 +137,9 @@ export type PackageBuildConfig = {
135
137
  * Prefix of the deploy environment variables.
136
138
  */
137
139
  envPrefix: string;
140
+ /**
141
+ * The bundle file Foundry loads, as the
142
+ * manifest spells it. Derived from the package id.
143
+ */
144
+ bundleEntry: string;
138
145
  };