@heroiclands/package-build 0.4.0 → 0.6.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.
@@ -51,14 +51,20 @@
51
51
  * npx package-build assets
52
52
  * npx package-build manifest
53
53
  * npx package-build lang check
54
+ * npx package-build lang coverage [--unused]
55
+ * npx package-build lang hardcoded
54
56
  * npx package-build bundle check
55
57
  * npx package-build release
56
58
  * npx package-build deploy <stage>
59
+ * npx package-build container <stage> <action>
60
+ * npx package-build e2e <seed|run|open|fast|sweep>
57
61
  *
58
62
  * In a consuming repository, wrapped as npm scripts — SoHL spells them:
59
63
  * npm run clean // → … clean
60
64
  * npm run build:assets // → … assets
61
65
  * npm run lint:lang // → … lang check
66
+ * npm run lint:lang-coverage // → … lang coverage
67
+ * npm run lint:lang-hardcoded // → … lang hardcoded
62
68
  * npm run lint:bundle-globals // → … bundle check
63
69
  * npm run build:pack-release // → … release
64
70
  * npm run push:qa // → … deploy qa
@@ -75,12 +81,36 @@ import { loadPackageBuildConfig } from "../config.mjs";
75
81
  import { loadPackConfig } from "@heroiclands/content-build/engine/pack-config";
76
82
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
77
83
  import { validateLangSource } from "../lang.mjs";
84
+ import {
85
+ analyzeCoverage,
86
+ collectScriptReferences,
87
+ collectTemplateReferences,
88
+ keyRootsOf,
89
+ mergeReferences,
90
+ } from "../coverage.mjs";
91
+ import { findHardcodedText, findTemplateSyntaxErrors } from "../templates.mjs";
78
92
  import { checkBundleLoading } from "../bundle.mjs";
79
93
  import { packRelease } from "../release.mjs";
80
94
  import { writeManifest } from "../manifest.mjs";
81
95
  import { deployStage } from "../deploy.mjs";
96
+ import { CONTAINER_ACTIONS, containerAction } from "../container.mjs";
97
+ import {
98
+ E2E_MODES,
99
+ e2eFast,
100
+ e2eRun,
101
+ e2eSweep,
102
+ seedTestWorld,
103
+ } from "../e2e.mjs";
82
104
  import { reportFindings } from "./report.mjs";
83
105
 
106
+ /**
107
+ * How many unreferenced keys a run prints before it summarizes the rest.
108
+ *
109
+ * Enough to see the shape of the problem, few enough that the errors above
110
+ * them are still on the screen.
111
+ */
112
+ const ADVISORY_PREVIEW = 20;
113
+
84
114
  /**
85
115
  * This package's own version, for `--version`.
86
116
  *
@@ -130,6 +160,55 @@ function handler(run) {
130
160
  };
131
161
  }
132
162
 
163
+ /**
164
+ * Load the repository's environment files.
165
+ *
166
+ * Done inside a handler rather than at module scope: `--help` and `--version`
167
+ * must answer in a directory with no environment file — or no configuration —
168
+ * at all.
169
+ *
170
+ * @param {object} config - The resolved package-build configuration.
171
+ * @returns {Promise<void>} Once `.env.local` and `.env` have been applied.
172
+ */
173
+ async function loadEnvironment(config) {
174
+ const dotenv = await import("dotenv");
175
+ dotenv.config({
176
+ path: path.join(config.rootDir, ".env.local"),
177
+ quiet: true,
178
+ });
179
+ dotenv.config({ path: path.join(config.rootDir, ".env"), quiet: true });
180
+ }
181
+
182
+ /**
183
+ * The repository's own `package.json`.
184
+ *
185
+ * @param {object} config - The resolved package-build configuration.
186
+ * @returns {object} The parsed manifest.
187
+ */
188
+ function readPackageJson(config) {
189
+ return JSON.parse(
190
+ fs.readFileSync(path.join(config.rootDir, "package.json"), "utf8"),
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Everything the user typed after an `e2e` action, verbatim.
196
+ *
197
+ * Read from the raw arguments rather than from yargs, because most of it is not
198
+ * this command line's to interpret: `--spec`, `--browser`, a `--` passthrough
199
+ * and whatever else belongs to the repository's suite. Parsing it here would
200
+ * mean this package learning the flags of a runner it deliberately knows
201
+ * nothing about.
202
+ *
203
+ * @param {string} action - The action that was run.
204
+ * @returns {string[]} The arguments after it.
205
+ */
206
+ function e2eTail(action) {
207
+ const raw = hideBin(process.argv);
208
+ const at = raw.indexOf(action);
209
+ return at === -1 ? [] : raw.slice(at + 1);
210
+ }
211
+
133
212
  /**
134
213
  * `clean` — remove this repository's build artifacts.
135
214
  *
@@ -246,12 +325,7 @@ function manifestCommand() {
246
325
  handler: handler(async () => {
247
326
  const config = loadPackageBuildConfig();
248
327
  const shared = loadPackConfig();
249
- const packageJson = JSON.parse(
250
- fs.readFileSync(
251
- path.join(config.rootDir, "package.json"),
252
- "utf8",
253
- ),
254
- );
328
+ const packageJson = readPackageJson(config);
255
329
 
256
330
  let flags;
257
331
  if (config.manifestFlags) {
@@ -290,6 +364,26 @@ function manifestCommand() {
290
364
  };
291
365
  }
292
366
 
367
+ /**
368
+ * Every file matching a glob, as `{ path, text }` with the path relative to the
369
+ * repository root — the form both the rules and the findings want.
370
+ *
371
+ * @param {string|readonly string[]} globs - Glob or globs, relative to the root.
372
+ * @param {string} rootDir - The repository root.
373
+ * @returns {{path: string, text: string}[]} The files, in path order.
374
+ */
375
+ function readMatching(globs, rootDir) {
376
+ return globSync([...(Array.isArray(globs) ? globs : [globs])], {
377
+ cwd: rootDir,
378
+ absolute: true,
379
+ })
380
+ .sort()
381
+ .map((file) => ({
382
+ path: path.relative(rootDir, file),
383
+ text: fs.readFileSync(file, "utf8"),
384
+ }));
385
+ }
386
+
293
387
  /**
294
388
  * `lang check` — verify every localization file survives `expandObject`.
295
389
  *
@@ -297,6 +391,251 @@ function manifestCommand() {
297
391
  * Foundry then drops the whole translation file silently. The rule is the
298
392
  * library's; the glob and any repository-specific guidance are data.
299
393
  *
394
+ * @param {Readonly<import("../config.mjs").PackageBuildConfig>} config
395
+ * @returns {void}
396
+ */
397
+ function langCheck(config) {
398
+ const files = readMatching(config.langSources, config.rootDir);
399
+ if (!files.length) {
400
+ die(
401
+ `no localization files matched \`${config.langSources}\` under ` +
402
+ `${config.rootDir}.`,
403
+ );
404
+ }
405
+
406
+ let total = 0;
407
+ for (const file of files) {
408
+ total += reportFindings(validateLangSource(file.text), {
409
+ file: file.path,
410
+ });
411
+ }
412
+
413
+ if (total) {
414
+ if (config.langHelp) console.error(`\n${config.langHelp}`);
415
+ process.exit(1);
416
+ }
417
+ console.log(
418
+ `package-build: ${files.length} localization file(s) are ` +
419
+ `expandObject-safe.`,
420
+ );
421
+ }
422
+
423
+ /**
424
+ * `lang coverage` — does every key the package references exist, and is every
425
+ * key it declares referenced?
426
+ *
427
+ * The two halves are not the same severity. A referenced key that is missing
428
+ * renders to a player as its own raw key string, so it fails the run; a key
429
+ * nothing references is reported and does not, because no scan can see every
430
+ * way a key is reached and a guard that fails over one teaches people to switch
431
+ * it off.
432
+ *
433
+ * A repository that *generates* keys by a convention of its own names a module
434
+ * in `packageBuild.lang.references`, exporting
435
+ * `references(context) -> ReferenceSet`. That is the same shape as
436
+ * `assetTransform` and `manifestFlags`, and for the same reason: only that
437
+ * repository can know its rule, and only this package can compare the result
438
+ * against the file.
439
+ *
440
+ * @param {Readonly<import("../config.mjs").PackageBuildConfig>} config
441
+ * @param {object} args - The parsed argv.
442
+ * @returns {Promise<void>}
443
+ */
444
+ async function langCoverage(config, args) {
445
+ const langFile = config.langPrimary;
446
+ const langPath = path.join(config.rootDir, langFile);
447
+ if (!fs.existsSync(langPath)) {
448
+ die(
449
+ `no localization file at ${langFile} — name the one this package ` +
450
+ `authors in \`packageBuild.lang.primary\`.`,
451
+ );
452
+ }
453
+ const langSource = fs.readFileSync(langPath, "utf8");
454
+
455
+ /**
456
+ * Report and exit, so the two exits — nothing to compare against, and a
457
+ * comparison that failed — read the same way.
458
+ *
459
+ * @param {object} analysis - What {@link analyzeCoverage} returned.
460
+ * @returns {void}
461
+ */
462
+ const finish = ({ findings, unreferenced, stats }) => {
463
+ const errors = reportFindings(findings, {});
464
+ // Capped by default: the advisory half of a large package is pages
465
+ // long, and pages of warnings on every build is how a guard stops being
466
+ // read at all. The count is always stated, so nothing is hidden.
467
+ const shown =
468
+ args.unused ? unreferenced : (
469
+ unreferenced.slice(0, ADVISORY_PREVIEW)
470
+ );
471
+ reportFindings(shown, {});
472
+ if (shown.length < unreferenced.length) {
473
+ console.error(
474
+ `package-build: ${unreferenced.length - shown.length} further ` +
475
+ `unreferenced key(s) not shown — run with --unused.`,
476
+ );
477
+ }
478
+
479
+ console.log(
480
+ `package-build: ${stats.declared} key(s) declared in ${langFile} · ` +
481
+ `${stats.referenced} referenced · ` +
482
+ `${stats.namespaces} namespace(s) · ` +
483
+ `${stats.patterns} dynamic shape(s) · ` +
484
+ `${stats.missing} missing · ` +
485
+ `${stats.unreferenced} unreferenced`,
486
+ );
487
+ if (errors) process.exit(1);
488
+ };
489
+
490
+ let declaredKeys;
491
+ try {
492
+ declaredKeys = Object.keys(JSON.parse(langSource));
493
+ } catch {
494
+ // Scanning would be pointless: with nothing to compare against, every
495
+ // key the package references reports as missing. `analyzeCoverage`
496
+ // says the one true thing instead.
497
+ finish(
498
+ analyzeCoverage({
499
+ langSource,
500
+ langFile,
501
+ references: {
502
+ keys: [],
503
+ namespaces: [],
504
+ patterns: [],
505
+ findings: [],
506
+ },
507
+ }),
508
+ );
509
+ return;
510
+ }
511
+
512
+ const roots = config.langKeyRoots ?? keyRootsOf(declaredKeys);
513
+ const scripts = readMatching(config.langScripts, config.rootDir);
514
+ const templates = readMatching(config.langTemplates, config.rootDir);
515
+ // Named rather than left to pass: with nothing scanned, every key reports
516
+ // as unreferenced and nothing reports as missing, so a run that looked at
517
+ // no files at all would exit zero and prove nothing.
518
+ if (!scripts.length && !templates.length) {
519
+ die(
520
+ `no sources matched \`${config.langScripts.join("`, `")}\` or ` +
521
+ `\`${config.langTemplates.join("`, `")}\` under ` +
522
+ `${config.rootDir}.`,
523
+ );
524
+ }
525
+ const sets = [
526
+ ...scripts.map((file) =>
527
+ collectScriptReferences(file.text, { file: file.path, roots }),
528
+ ),
529
+ ...templates.map((file) =>
530
+ collectTemplateReferences(file.text, { file: file.path, roots }),
531
+ ),
532
+ ];
533
+
534
+ if (config.langReferences) {
535
+ const module = await import(`file://${config.langReferences}`).catch(
536
+ (err) =>
537
+ die(
538
+ `cannot load \`packageBuild.lang.references\` ` +
539
+ `(${config.langReferences}): ${err.message}`,
540
+ ),
541
+ );
542
+ if (typeof module.references !== "function") {
543
+ die(
544
+ `\`packageBuild.lang.references\` ` +
545
+ `(${config.langReferences}) exports no \`references\` ` +
546
+ `function. It must export ` +
547
+ `\`references(context) -> ReferenceSet\`.`,
548
+ );
549
+ }
550
+ sets.push(
551
+ await module.references({
552
+ config: loadPackConfig(),
553
+ rootDir: config.rootDir,
554
+ roots,
555
+ // The sources, already read, so the contributor sees exactly
556
+ // the text the built-in scan saw.
557
+ files: scripts,
558
+ }),
559
+ );
560
+ }
561
+
562
+ finish(
563
+ analyzeCoverage({
564
+ langSource,
565
+ langFile,
566
+ references: mergeReferences(sets),
567
+ retained: config.langRetained,
568
+ roots,
569
+ }),
570
+ );
571
+ }
572
+
573
+ /**
574
+ * `lang hardcoded` — does the markup's user-visible text go through
575
+ * localization, and does each template still compile?
576
+ *
577
+ * The reverse of `lang coverage`, which walks key → file and is blind to a
578
+ * template that mentions no key at all.
579
+ *
580
+ * @param {Readonly<import("../config.mjs").PackageBuildConfig>} config
581
+ * @returns {void}
582
+ */
583
+ function langHardcoded(config) {
584
+ const templates = readMatching(config.langTemplates, config.rootDir);
585
+ if (!templates.length) {
586
+ die(
587
+ `no templates matched ` +
588
+ `\`${config.langTemplates.join("`, `")}\` under ` +
589
+ `${config.rootDir}.`,
590
+ );
591
+ }
592
+
593
+ let literals = 0;
594
+ let broken = 0;
595
+ for (const file of templates) {
596
+ literals += reportFindings(
597
+ findHardcodedText(file.text, { allow: config.langAllow }),
598
+ { file: file.path },
599
+ );
600
+ broken += reportFindings(findTemplateSyntaxErrors(file.text), {
601
+ file: file.path,
602
+ });
603
+ }
604
+
605
+ if (literals || broken) {
606
+ if (literals) {
607
+ console.error(
608
+ `\npackage-build: ${literals} user-visible literal(s) are not ` +
609
+ `localized. Replace each with a {{localize}} call and add ` +
610
+ `the key, or record it in \`packageBuild.lang.allow\` with ` +
611
+ `the reason it is not prose.`,
612
+ );
613
+ }
614
+ if (broken) {
615
+ console.error(
616
+ `\npackage-build: ${broken} template(s) do not compile. A ` +
617
+ `{{localize …}} nested inside another mustache is legal in ` +
618
+ `an HTML attribute but a parse error inside a helper's ` +
619
+ `hash — use a (localize …) subexpression there.`,
620
+ );
621
+ }
622
+ process.exit(1);
623
+ }
624
+
625
+ console.log(
626
+ `package-build: ${templates.length} template(s) fully localized and ` +
627
+ `compiling.`,
628
+ );
629
+ }
630
+
631
+ /**
632
+ * `lang <action>` — the three localization guards.
633
+ *
634
+ * They are three questions about one subject, and each is blind to what the
635
+ * others see: `check` asks whether the file will load at all, `coverage`
636
+ * whether the keys and the code agree, `hardcoded` whether the markup ever
637
+ * asks for a key in the first place.
638
+ *
300
639
  * @returns {object} The yargs command module.
301
640
  */
302
641
  function langCommand() {
@@ -304,39 +643,25 @@ function langCommand() {
304
643
  command: "lang <action>",
305
644
  describe: "Localization checks",
306
645
  builder: (y) =>
307
- y.positional("action", {
308
- choices: ["check"],
309
- describe: "check: verify the files are expandObject-safe",
310
- }),
311
- handler: handler(() => {
646
+ y
647
+ .positional("action", {
648
+ choices: ["check", "coverage", "hardcoded"],
649
+ describe:
650
+ "check: the files are expandObject-safe · " +
651
+ "coverage: keys and code agree · " +
652
+ "hardcoded: templates localize their text",
653
+ })
654
+ .option("unused", {
655
+ type: "boolean",
656
+ default: false,
657
+ describe:
658
+ "coverage: list every unreferenced key, not a preview",
659
+ }),
660
+ handler: handler(async (args) => {
312
661
  const config = loadPackageBuildConfig();
313
- const files = globSync(config.langSources, {
314
- cwd: config.rootDir,
315
- absolute: true,
316
- });
317
- if (!files.length) {
318
- die(
319
- `no localization files matched ` +
320
- `\`${config.langSources}\` under ${config.rootDir}.`,
321
- );
322
- }
323
-
324
- let total = 0;
325
- for (const file of files.sort()) {
326
- total += reportFindings(
327
- validateLangSource(fs.readFileSync(file, "utf8")),
328
- { file: path.relative(config.rootDir, file) },
329
- );
330
- }
331
-
332
- if (total) {
333
- if (config.langHelp) console.error(`\n${config.langHelp}`);
334
- process.exit(1);
335
- }
336
- console.log(
337
- `package-build: ${files.length} localization file(s) are ` +
338
- `expandObject-safe.`,
339
- );
662
+ if (args.action === "check") return langCheck(config);
663
+ if (args.action === "hardcoded") return langHardcoded(config);
664
+ return langCoverage(config, args);
340
665
  }),
341
666
  };
342
667
  }
@@ -457,17 +782,7 @@ function deployCommand() {
457
782
  handler: handler(async (args) => {
458
783
  const config = loadPackageBuildConfig();
459
784
 
460
- // Loaded here rather than at module scope: `--help` must answer in
461
- // a repository that has no environment file at all.
462
- const dotenv = await import("dotenv");
463
- dotenv.config({
464
- path: path.join(config.rootDir, ".env.local"),
465
- quiet: true,
466
- });
467
- dotenv.config({
468
- path: path.join(config.rootDir, ".env"),
469
- quiet: true,
470
- });
785
+ await loadEnvironment(config);
471
786
 
472
787
  const { stage } = await deployStage({
473
788
  stage: args.stage,
@@ -482,6 +797,122 @@ function deployCommand() {
482
797
  };
483
798
  }
484
799
 
800
+ /**
801
+ * `container <stage> <action>` — run a stage's Foundry in a container.
802
+ *
803
+ * The seam is the deploy's own: `deploy <stage>` installs the staged package
804
+ * into `FOUNDRYVTT_<STAGE>_DATA`, and this mounts that same directory and
805
+ * serves it. Nothing about the destination is stated twice.
806
+ *
807
+ * Every action shares one shape — a stage and an action — so this is a single
808
+ * command with a closed set of choices rather than eight of them. The stage
809
+ * leads, as it does in `deploy <stage>`, which is also what lets a consumer
810
+ * wrap it once per stage: `npm run container:dev start`.
811
+ *
812
+ * @returns {object} The yargs command module.
813
+ */
814
+ function containerCommand() {
815
+ return {
816
+ command: "container <stage> <action>",
817
+ describe: "Run a stage's Foundry in a container",
818
+ builder: (y) =>
819
+ y
820
+ .positional("stage", {
821
+ type: "string",
822
+ describe: "Target stage (e.g. dev, qa, prod, test)",
823
+ })
824
+ .positional("action", {
825
+ choices: [...CONTAINER_ACTIONS],
826
+ describe: "What to do with the stage's container",
827
+ }),
828
+ handler: handler(async (args) => {
829
+ const config = loadPackageBuildConfig();
830
+ await loadEnvironment(config);
831
+ const status = containerAction({
832
+ action: String(args.action),
833
+ stage: String(args.stage).trim().toLowerCase(),
834
+ config,
835
+ log: (message) => console.log(message),
836
+ });
837
+ process.exit(status);
838
+ }),
839
+ };
840
+ }
841
+
842
+ /** What `package-build e2e` can be asked to do. */
843
+ const E2E_ACTIONS = ["seed", ...E2E_MODES, "fast", "sweep"];
844
+
845
+ /**
846
+ * `e2e <action>` — stand a Foundry world up and drive a suite against it.
847
+ *
848
+ * **The suite itself is never this package's.** Seeding a disposable world,
849
+ * waiting for it to become *active* rather than merely reachable, and tearing
850
+ * it down again are nobody's local problem; what runs against it is entirely
851
+ * the repository's, and is named in `packageBuild.e2e.suite`.
852
+ *
853
+ * The actions answer different questions. `seed` writes the world.
854
+ * `run` and `open` are the from-scratch path — deploy, reseed, recreate, wait,
855
+ * run — and are the only ones that may change Foundry build, because a seeded
856
+ * world is stamped with the build that created it. `fast` is the iteration
857
+ * loop. `sweep` is the same full run against a build the repository does not
858
+ * pin, so `compatibility.verified` can be evidence rather than hope.
859
+ *
860
+ * Everything after the action is the suite's, and is passed through untouched.
861
+ *
862
+ * @returns {object} The yargs command module.
863
+ */
864
+ function e2eCommand() {
865
+ return {
866
+ command: "e2e <action>",
867
+ describe: "Run a suite against a served Foundry world",
868
+ builder: (y) =>
869
+ y
870
+ .positional("action", {
871
+ choices: E2E_ACTIONS,
872
+ describe:
873
+ "seed: write the world · run/open: from scratch · " +
874
+ "fast: rebuild and re-run · sweep: another Foundry build",
875
+ })
876
+ // Everything after the action belongs to the suite or to the
877
+ // fast loop, so this command line must not judge it.
878
+ .strict(false),
879
+ handler: handler(async (args) => {
880
+ const action = String(args.action);
881
+ const config = loadPackageBuildConfig();
882
+ await loadEnvironment(config);
883
+ const log = (message) => console.log(message);
884
+ const tail = e2eTail(action);
885
+
886
+ if (action === "seed") {
887
+ await seedTestWorld({
888
+ config,
889
+ packageJson: readPackageJson(config),
890
+ log,
891
+ });
892
+ return;
893
+ }
894
+
895
+ const status =
896
+ action === "fast" ? await e2eFast({ config, argv: tail, log })
897
+ : action === "sweep" ?
898
+ await e2eSweep({
899
+ config,
900
+ packageJson: readPackageJson(config),
901
+ argv: tail,
902
+ log,
903
+ })
904
+ : await e2eRun({
905
+ config,
906
+ packageJson: readPackageJson(config),
907
+ mode: /** @type {"run"|"open"} */ (action),
908
+ suiteArgs: tail,
909
+ log,
910
+ });
911
+ process.exit(status);
912
+ }),
913
+ };
914
+ }
915
+
485
916
  yargs(hideBin(process.argv))
486
917
  .scriptName("package-build")
487
918
  .command(cleanCommand())
@@ -491,6 +922,8 @@ yargs(hideBin(process.argv))
491
922
  .command(bundleCommand())
492
923
  .command(releaseCommand())
493
924
  .command(deployCommand())
925
+ .command(containerCommand())
926
+ .command(e2eCommand())
494
927
  .demandCommand(1, "Name a command.")
495
928
  .strict()
496
929
  .version(ownVersion())
package/bin/report.mjs CHANGED
@@ -42,6 +42,9 @@ import { emitDiagnostic } from "@heroiclands/content-build/engine/diagnostics";
42
42
  *
43
43
  * @typedef {object} Finding
44
44
  * @property {string} message What is wrong, in one sentence.
45
+ * @property {string} [file] The file it is about, when the
46
+ * rule knows — a rule spanning many files does, and one handed a single
47
+ * file's text does not.
45
48
  * @property {"warning"|"error"} [severity] Defaults to `error`.
46
49
  * @property {number} [line] 1-based line, when known.
47
50
  * @property {number} [column] 1-based column, when known.
@@ -59,11 +62,16 @@ import { emitDiagnostic } from "@heroiclands/content-build/engine/diagnostics";
59
62
  * top of the file every time and reads exactly like a real position. A column
60
63
  * without a line is dropped for the same reason: it locates nothing on its own.
61
64
  *
65
+ * **Unless the finding names its own.** A rule handed one file's text cannot;
66
+ * a rule handed a whole repository — coverage, which compares the localization
67
+ * file against every source that references it — can name nothing else, since
68
+ * no single path is right for both halves of what it found.
69
+ *
62
70
  * @param {Finding[]} findings - What the rule returned.
63
71
  * @param {object} opts
64
- * @param {string} opts.file - Path to the file, relative to the working
72
+ * @param {string} [opts.file] - Path to the file, relative to the working
65
73
  * directory, so the emitted line is one an editor or `cc`-style parser can
66
- * open.
74
+ * open. Optional only for a rule whose every finding carries its own.
67
75
  * @returns {Array<{file: string, line?: number, column?: number,
68
76
  * severity: "warning"|"error", message: string}>} The diagnostics, in the
69
77
  * order the rule reported them.
@@ -74,7 +82,7 @@ export function toDiagnostics(findings, { file }) {
74
82
  const hasColumn =
75
83
  finding.column !== undefined && finding.column !== null;
76
84
  return {
77
- file,
85
+ file: finding.file ?? file,
78
86
  ...(hasLine ? { line: finding.line } : {}),
79
87
  // Only alongside a line: `formatLocator` ignores a lone column, and
80
88
  // carrying it anyway would invite a reader to trust it.
@@ -93,7 +101,8 @@ export function toDiagnostics(findings, { file }) {
93
101
  *
94
102
  * @param {Finding[]} findings - What the rule returned.
95
103
  * @param {object} opts
96
- * @param {string} opts.file - Path to the file, relative to the working dir.
104
+ * @param {string} [opts.file] - Path to the file, relative to the working dir.
105
+ * Optional only when every finding carries its own.
97
106
  * @param {(d: object) => void} [opts.emit] - How to emit one diagnostic.
98
107
  * Injectable so a test can capture the emitted shape without reaching for
99
108
  * the console; defaults to content-build's `emitDiagnostic`, which writes