@heroiclands/package-build 20.0.0 → 20.2.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 +476 -0
- package/CONTENT.md +185 -25
- package/README.md +43 -4
- package/bin/content-build.mjs +87 -2
- package/config.mjs +9 -1
- package/content-config.mjs +89 -18
- package/e2e.mjs +297 -3
- package/engine/content-charset.mjs +434 -0
- package/engine/content-icons.mjs +388 -0
- package/engine/foreign-catalog.mjs +109 -7
- package/engine/frontmatter-lint.mjs +191 -29
- package/engine/generate.mjs +5 -2
- package/engine/helpers.mjs +10 -1
- package/engine/index.mjs +6 -0
- package/engine/note-claims.mjs +208 -5
- package/engine/pack-config.mjs +102 -12
- package/engine/pack-router.mjs +0 -0
- package/engine/prose-config.mjs +42 -0
- package/engine/prose-lint.mjs +126 -0
- package/engine/schema-extract.mjs +13 -0
- package/package.json +1 -1
- package/types/config.d.mts +7 -0
- package/types/e2e.d.mts +130 -3
- package/types/engine/content-charset.d.mts +127 -0
- package/types/engine/content-icons.d.mts +151 -0
- package/types/engine/foreign-catalog.d.mts +38 -2
- package/types/engine/frontmatter-lint.d.mts +146 -28
- package/types/engine/helpers.d.mts +8 -0
- package/types/engine/index.d.mts +2 -0
- package/types/engine/note-claims.d.mts +67 -0
- package/types/engine/pack-config.d.mts +35 -0
- package/types/engine/prose-config.d.mts +41 -0
- package/types/engine/prose-lint.d.mts +36 -0
package/engine/pack-config.mjs
CHANGED
|
@@ -47,14 +47,38 @@
|
|
|
47
47
|
* actually needed: every accessor in the engine funnels through here, so
|
|
48
48
|
* anything that reads configuration throws with the message below.
|
|
49
49
|
*
|
|
50
|
-
* **Located by walking up from
|
|
51
|
-
* The config file sits at the root of the repository
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
50
|
+
* **Located by walking up from the working directory, and from this module only
|
|
51
|
+
* when that finds nothing.** The config file sits at the root of the repository
|
|
52
|
+
* being built, and a build is run inside that repository — so the walk from
|
|
53
|
+
* `process.cwd()` finds it from the root, from `packages/`, from anywhere
|
|
54
|
+
* below. Climbing from this module instead finds the same file too, right up
|
|
55
|
+
* until the installed package is not the one the caller is standing in: a git
|
|
56
|
+
* worktree nested under its parent checkout with no `node_modules` of its own
|
|
57
|
+
* resolves `@heroiclands/package-build` out of the *parent's*, because Node's
|
|
58
|
+
* resolution walks parent directories. `import.meta.dirname` is then inside the
|
|
59
|
+
* parent, the walk lands on the parent's configuration, and the build compiles
|
|
60
|
+
* the parent's content tree into the parent's `build/` and exits 0 — saying so
|
|
61
|
+
* only in paths that are easy to read past (#364).
|
|
62
|
+
*
|
|
63
|
+
* That failure is undetectable on exactly the work most likely to provoke it.
|
|
64
|
+
* The usual tell is a zero diff where a change was expected; an
|
|
65
|
+
* output-preserving sweep *expects* zero differences, so the tell is gone and a
|
|
66
|
+
* wrong-tree build produces confident evidence for a tree nobody touched. Order
|
|
67
|
+
* of resolution is the fix, because reviewer vigilance cannot be.
|
|
68
|
+
*
|
|
69
|
+
* The module walk stays, as the fallback for an invocation from outside any
|
|
70
|
+
* repository, and is never *preferred*: a configuration found above the
|
|
71
|
+
* installed package rather than above the working directory is not the one a
|
|
72
|
+
* caller meant. When both walks find one and they disagree, the ignored one is
|
|
73
|
+
* named in a warning rather than passed over — that disagreement is also the
|
|
74
|
+
* only cheap signal that this tree is building on another checkout's
|
|
75
|
+
* `node_modules`. `PACKAGE_BUILD_CONFIG` names the file explicitly and skips
|
|
76
|
+
* both walks, which is why it was the workaround.
|
|
77
|
+
*
|
|
78
|
+
* What #1508 removed stays removed. The property it bought was not "resolve
|
|
79
|
+
* from the module"; it was that a build reads one tree however it was launched,
|
|
80
|
+
* and an upward walk from the working directory keeps that — every directory
|
|
81
|
+
* inside a repository resolves that repository's single configuration.
|
|
58
82
|
*
|
|
59
83
|
* **Loaded synchronously.** A YAML config is parsed synchronously as a matter
|
|
60
84
|
* of course; an `.mjs` one is loaded with `require` rather than `await import`,
|
|
@@ -81,7 +105,12 @@ import { createRequire } from "node:module";
|
|
|
81
105
|
import YAML from "yaml";
|
|
82
106
|
|
|
83
107
|
import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
|
|
84
|
-
import {
|
|
108
|
+
import {
|
|
109
|
+
emitDiagnostic,
|
|
110
|
+
formatDiagnostic,
|
|
111
|
+
positionOfYamlPath,
|
|
112
|
+
yamlKeyPath,
|
|
113
|
+
} from "./diagnostics.mjs";
|
|
85
114
|
|
|
86
115
|
/** The stem every consuming repository declares its build under. */
|
|
87
116
|
export const CONFIG_BASENAME = "package-build.config";
|
|
@@ -144,6 +173,41 @@ export function findConfigFile(from) {
|
|
|
144
173
|
}
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Which configuration file a build launched here should read, and what each
|
|
178
|
+
* walk found.
|
|
179
|
+
*
|
|
180
|
+
* Kept separate from {@link loadPackConfig} because the *choice* is worth being
|
|
181
|
+
* able to ask about without loading anything: the two walks disagreeing is the
|
|
182
|
+
* observable form of #364, and a caller that wants to report it — or a test
|
|
183
|
+
* that wants to describe it — should not have to reproduce the resolution and
|
|
184
|
+
* risk disagreeing with the loader about it. It performs I/O, and is named for
|
|
185
|
+
* it, like the {@link findConfigFile} it calls twice.
|
|
186
|
+
*
|
|
187
|
+
* `PACKAGE_BUILD_CONFIG` is deliberately not consulted here. An explicit name
|
|
188
|
+
* is not a search result: {@link loadPackConfig} short-circuits on it before it
|
|
189
|
+
* ever asks, so there is no walk to report and nothing to disagree with.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} [from] - Where to walk up from; both default to the real
|
|
192
|
+
* thing, and are parameters only so a caller can describe a tree it is not
|
|
193
|
+
* standing in.
|
|
194
|
+
* @param {string} [from.cwd] - The directory the build was launched in.
|
|
195
|
+
* @param {string} [from.moduleDir] - The directory this module sits in.
|
|
196
|
+
* @returns {{path: string|undefined, fromCwd: string|undefined, fromModule: string|undefined}}
|
|
197
|
+
* The file to read, and each walk's own answer — the same file in an ordinary
|
|
198
|
+
* build, different ones in a worktree resolving the toolchain out of its
|
|
199
|
+
* parent checkout.
|
|
200
|
+
* @throws {Error} As {@link findConfigFile}, when one directory holds more than
|
|
201
|
+
* one configuration.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveConfigFile({ cwd = process.cwd(), moduleDir = import.meta.dirname } = {}) {
|
|
204
|
+
const fromCwd = findConfigFile(cwd);
|
|
205
|
+
const fromModule = findConfigFile(moduleDir);
|
|
206
|
+
// `??`, not `||`: the module walk is a fallback for finding *nothing*, never
|
|
207
|
+
// a tie-break between two answers.
|
|
208
|
+
return { path: fromCwd ?? fromModule, fromCwd, fromModule };
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
const require = createRequire(import.meta.url);
|
|
148
212
|
|
|
149
213
|
/**
|
|
@@ -607,20 +671,46 @@ export function loadPackConfig() {
|
|
|
607
671
|
if (loaded) return loaded;
|
|
608
672
|
|
|
609
673
|
const explicit = process.env.PACKAGE_BUILD_CONFIG;
|
|
610
|
-
const
|
|
674
|
+
const found = explicit ? undefined : resolveConfigFile();
|
|
675
|
+
const configPath = explicit ? path.resolve(explicit) : found.path;
|
|
611
676
|
|
|
612
677
|
if (!configPath || !fs.existsSync(configPath)) {
|
|
613
678
|
throw new Error(
|
|
614
679
|
explicit ?
|
|
615
680
|
`package-build: PACKAGE_BUILD_CONFIG names ${configPath}, ` +
|
|
616
681
|
`which does not exist.`
|
|
682
|
+
// Both origins, because either walk could have found one and
|
|
683
|
+
// naming only the module's would send a reader looking inside
|
|
684
|
+
// `node_modules/` for a file that belongs in their own root.
|
|
617
685
|
: `package-build: no ${CONFIG_FILENAMES.join(" or ")} found at ` +
|
|
618
|
-
`or above ${
|
|
619
|
-
|
|
686
|
+
`or above ${process.cwd()}, nor at or above ` +
|
|
687
|
+
`${import.meta.dirname}. A consuming repository declares ` +
|
|
688
|
+
`its build in one file at its root; set ` +
|
|
620
689
|
`PACKAGE_BUILD_CONFIG to name it elsewhere.`,
|
|
621
690
|
);
|
|
622
691
|
}
|
|
623
692
|
|
|
693
|
+
// Two different files, one of which is about to be ignored. Said out loud
|
|
694
|
+
// because the alternative is what #364 was: a build that reads the parent
|
|
695
|
+
// checkout's configuration, compiles the parent's tree, and reports it only
|
|
696
|
+
// in absolute paths nobody rereads. A warning rather than an error — the
|
|
697
|
+
// shape is legitimate, and the working directory's answer is the right one
|
|
698
|
+
// — but never silence.
|
|
699
|
+
if (found?.fromCwd && found.fromModule && found.fromCwd !== found.fromModule) {
|
|
700
|
+
emitDiagnostic({
|
|
701
|
+
severity: "warning",
|
|
702
|
+
message:
|
|
703
|
+
`package-build: reading ${found.fromCwd}, the configuration ` +
|
|
704
|
+
`above this working directory. The installed ` +
|
|
705
|
+
`@heroiclands/package-build sits under a different ` +
|
|
706
|
+
`repository, whose own ${found.fromModule} is being ignored — ` +
|
|
707
|
+
`usually because this tree has no \`node_modules\` of its own ` +
|
|
708
|
+
`and resolved the toolchain out of a parent checkout. Run ` +
|
|
709
|
+
`\`npm ci\` here, or set PACKAGE_BUILD_CONFIG, to say which ` +
|
|
710
|
+
`tree is meant.`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
624
714
|
loaded =
|
|
625
715
|
configPath.endsWith(".mjs") ?
|
|
626
716
|
loadCodeConfig(configPath)
|
package/engine/pack-router.mjs
CHANGED
|
Binary file
|
package/engine/prose-config.mjs
CHANGED
|
@@ -134,6 +134,48 @@ export function sharedPrettierOptionsFor(file) {
|
|
|
134
134
|
return /\.md$/i.test(file) ? { ...PRETTIER_BASE, ...PRETTIER_MARKDOWN } : { ...PRETTIER_BASE };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Where a resolved Prettier configuration disagrees with the shared one.
|
|
139
|
+
*
|
|
140
|
+
* The runner resolves each file's options as *either* the consumer's own config
|
|
141
|
+
* or {@link sharedPrettierOptionsFor}, never a merge. That is what bare Prettier
|
|
142
|
+
* does and it is the contract — but it means the conventions this package exists
|
|
143
|
+
* to publish hold by convention alone, and they lapse in two opposite directions
|
|
144
|
+
* (#133). A consumer that declares any config of its own gets whatever that
|
|
145
|
+
* config says: spread {@link PRETTIER_BASE} without the markdown override and
|
|
146
|
+
* every note reindents at 4, the reindentation the override was added to prevent
|
|
147
|
+
* (#76); write a partial `.prettierrc` such as `{"tabWidth": 2}` and
|
|
148
|
+
* `printWidth`, `trailingComma`, `experimentalTernaries` and the rest fall back
|
|
149
|
+
* to Prettier's own defaults. A consumer that declares *nothing* formats one way
|
|
150
|
+
* under this command and another under a bare `npx prettier`.
|
|
151
|
+
*
|
|
152
|
+
* This is the comparison that makes either absence visible. It is a **report,
|
|
153
|
+
* not a merge**: what a consumer declared still wins, and a deliberate local
|
|
154
|
+
* choice stays possible — it stops being silent, and nothing here fails a build
|
|
155
|
+
* over it.
|
|
156
|
+
*
|
|
157
|
+
* Every shared value is a primitive, so `!==` is the whole comparison. An option
|
|
158
|
+
* holding an object would need a deeper one, and the shared set has none —
|
|
159
|
+
* `overrides` is not compared, because `resolveConfig` has already applied and
|
|
160
|
+
* removed it by the time a configuration reaches this.
|
|
161
|
+
*
|
|
162
|
+
* @param {object|null|undefined} resolved - What `prettier.resolveConfig`
|
|
163
|
+
* returned for `file`, with the consumer's own `overrides` already applied.
|
|
164
|
+
* `null` — no configuration at all — reports every shared key as absent.
|
|
165
|
+
* @param {string} file - Path the options were resolved for. Decides whether
|
|
166
|
+
* {@link PRETTIER_MARKDOWN} is part of what is expected.
|
|
167
|
+
* @returns {Array<{key: string, shared: unknown, local: unknown}>} One entry per
|
|
168
|
+
* shared key the resolved configuration does not carry the value of, in the
|
|
169
|
+
* order {@link PRETTIER_BASE} declares them. `local` is `undefined` where the
|
|
170
|
+
* key is absent entirely, which is not the same finding as a key set to
|
|
171
|
+
* something else and is reported differently.
|
|
172
|
+
*/
|
|
173
|
+
export function sharedPrettierDivergence(resolved, file) {
|
|
174
|
+
return Object.entries(sharedPrettierOptionsFor(file))
|
|
175
|
+
.filter(([key, shared]) => resolved?.[key] !== shared)
|
|
176
|
+
.map(([key, shared]) => ({ key, shared, local: resolved?.[key] }));
|
|
177
|
+
}
|
|
178
|
+
|
|
137
179
|
/**
|
|
138
180
|
* The markdownlint rules — the structural checks Prettier cannot make.
|
|
139
181
|
*
|
package/engine/prose-lint.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
MARKDOWNLINT_CONFIG,
|
|
39
39
|
MARKDOWN_GLOBS,
|
|
40
40
|
MARKDOWN_IGNORES,
|
|
41
|
+
sharedPrettierDivergence,
|
|
41
42
|
sharedPrettierOptionsFor,
|
|
42
43
|
} from "./prose-config.mjs";
|
|
43
44
|
|
|
@@ -78,6 +79,27 @@ const IGNORE_FILES = Object.freeze([".gitignore", ".prettierignore"]);
|
|
|
78
79
|
*/
|
|
79
80
|
const MAX_FORMAT_PASSES = 3;
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The two paths whose resolved configuration stands for the repository's.
|
|
84
|
+
*
|
|
85
|
+
* Prettier resolves a configuration *per file*, so asking what a repository is
|
|
86
|
+
* configured to do means asking about a file. These are the two answers that
|
|
87
|
+
* differ: markdown carries the shared `tabWidth` override and everything else
|
|
88
|
+
* does not, so a single probe would check half the conventions and miss the one
|
|
89
|
+
* most worth checking (#133).
|
|
90
|
+
*
|
|
91
|
+
* Ordinary names at the repository root, and neither has to exist —
|
|
92
|
+
* `resolveConfig` reads the path to walk up from it and to match `overrides`
|
|
93
|
+
* against it, never the file. That is also the limit of what this can say: it
|
|
94
|
+
* reports the configuration a file *at the root* resolves to, so an override a
|
|
95
|
+
* consumer scoped to some subtree of its own is outside the question being
|
|
96
|
+
* asked, and rightly so.
|
|
97
|
+
*/
|
|
98
|
+
const CONVENTION_PROBES = Object.freeze({ code: "index.mjs", markdown: "README.md" });
|
|
99
|
+
|
|
100
|
+
/** The one line a consumer writes to adopt the shared configuration verbatim. */
|
|
101
|
+
const SHARED_CONFIG_RE_EXPORT = 'export { default } from "@heroiclands/package-build/prettier";';
|
|
102
|
+
|
|
81
103
|
/**
|
|
82
104
|
* Every file under a root, minus the directories nothing should walk.
|
|
83
105
|
*
|
|
@@ -245,6 +267,110 @@ export async function checkFormatting(root, opts = {}) {
|
|
|
245
267
|
return { findings, checked, written };
|
|
246
268
|
}
|
|
247
269
|
|
|
270
|
+
/**
|
|
271
|
+
* One divergence as the sentence a diagnostic carries.
|
|
272
|
+
*
|
|
273
|
+
* The two cases read differently on purpose. A key set to something else is a
|
|
274
|
+
* choice someone made and can defend; a key that is simply absent is the
|
|
275
|
+
* silent half of #133 — the consumer did not choose Prettier's default, it
|
|
276
|
+
* arrived because declaring one option discards every option not restated.
|
|
277
|
+
*
|
|
278
|
+
* @param {{key: string, shared: unknown, local: unknown}} divergence - From
|
|
279
|
+
* {@link sharedPrettierDivergence}.
|
|
280
|
+
* @param {string} [scope=""] - Which files this is about, when it is not all of
|
|
281
|
+
* them. Prefixed to the key, so the line reads `markdown \`tabWidth\` …`.
|
|
282
|
+
* @returns {string} The message.
|
|
283
|
+
*/
|
|
284
|
+
function divergenceMessage({ key, shared, local }, scope = "") {
|
|
285
|
+
const here =
|
|
286
|
+
local === undefined ?
|
|
287
|
+
"is not set here, so Prettier's own default applies"
|
|
288
|
+
: `is ${JSON.stringify(local)} here`;
|
|
289
|
+
return `${scope}\`${key}\` ${here}; the shared configuration says ${JSON.stringify(shared)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Report where a repository's own Prettier configuration parts from the shared
|
|
294
|
+
* one — or that it has none at all (#133).
|
|
295
|
+
*
|
|
296
|
+
* **Warnings, every one of them.** A consumer's config wins by design and this
|
|
297
|
+
* does not change that; it only refuses to let the divergence be silent, which
|
|
298
|
+
* is the whole of what the issue asks for. Turning any of this into an error
|
|
299
|
+
* would make the shared conventions mandatory, and they are a default.
|
|
300
|
+
*
|
|
301
|
+
* The no-configuration case is the sharper one and is reported even though the
|
|
302
|
+
* command itself behaves correctly there: with no config file the shared
|
|
303
|
+
* conventions reach `content-build format` and reach *nothing else*, so an
|
|
304
|
+
* editor's format-on-save and a bare `npx prettier --check .` apply Prettier's
|
|
305
|
+
* own defaults to the same tree, and the two take turns rewriting the same
|
|
306
|
+
* lines. That is not hypothetical — it is what the config files in
|
|
307
|
+
* `sohl-thalorna` and `sohl-kethira-basic` were added to stop.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} root - Repository to ask about.
|
|
310
|
+
* @param {object} [opts]
|
|
311
|
+
* @param {object} [opts.prettier] - The Prettier module, for tests.
|
|
312
|
+
* @returns {Promise<{findings: Array<{file?: string, severity: string,
|
|
313
|
+
* message: string}>, configFile: string|null}>} The findings and the config
|
|
314
|
+
* file they are about, which is `null` when the repository declares none. A
|
|
315
|
+
* finding about a missing file carries no `file`: #17's rule is to drop a
|
|
316
|
+
* field rather than invent one.
|
|
317
|
+
*/
|
|
318
|
+
export async function checkPrettierConventions(root, opts = {}) {
|
|
319
|
+
const prettier = opts.prettier ?? (await import("prettier"));
|
|
320
|
+
const base = path.resolve(root);
|
|
321
|
+
const probe = (name) => path.join(base, name);
|
|
322
|
+
|
|
323
|
+
const configFile = await prettier.resolveConfigFile(probe(CONVENTION_PROBES.code));
|
|
324
|
+
if (!configFile) {
|
|
325
|
+
return {
|
|
326
|
+
findings: [
|
|
327
|
+
{
|
|
328
|
+
severity: "warning",
|
|
329
|
+
message:
|
|
330
|
+
"this repository declares no Prettier configuration, so `content-build " +
|
|
331
|
+
"format` applies the shared conventions while an editor and a bare `npx " +
|
|
332
|
+
"prettier` apply Prettier's own to the same tree; declare them in a " +
|
|
333
|
+
`prettier.config.mjs — ${SHARED_CONFIG_RE_EXPORT}`,
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
configFile: null,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** @param {string} name - One of {@link CONVENTION_PROBES}. */
|
|
341
|
+
const divergenceFor = async (name) =>
|
|
342
|
+
sharedPrettierDivergence(
|
|
343
|
+
await prettier.resolveConfig(probe(name), { editorconfig: false }),
|
|
344
|
+
probe(name),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const code = await divergenceFor(CONVENTION_PROBES.code);
|
|
348
|
+
const markdown = await divergenceFor(CONVENTION_PROBES.markdown);
|
|
349
|
+
|
|
350
|
+
const findings = code.map((divergence) => ({
|
|
351
|
+
file: configFile,
|
|
352
|
+
severity: "warning",
|
|
353
|
+
message: divergenceMessage(divergence),
|
|
354
|
+
}));
|
|
355
|
+
for (const divergence of markdown) {
|
|
356
|
+
// A key that resolves the same way everywhere is one finding, not two.
|
|
357
|
+
// Only what markdown alone gets wrong is worth a line of its own — and
|
|
358
|
+
// it is the line that matters most, `tabWidth` being the value a note
|
|
359
|
+
// moving between repositories reindents on.
|
|
360
|
+
const everywhere = code.some(
|
|
361
|
+
(other) => other.key === divergence.key && Object.is(other.local, divergence.local),
|
|
362
|
+
);
|
|
363
|
+
if (everywhere) continue;
|
|
364
|
+
findings.push({
|
|
365
|
+
file: configFile,
|
|
366
|
+
severity: "warning",
|
|
367
|
+
message: divergenceMessage(divergence, "markdown "),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { findings, configFile };
|
|
372
|
+
}
|
|
373
|
+
|
|
248
374
|
/**
|
|
249
375
|
* One markdownlint result as a diagnostic.
|
|
250
376
|
*
|
|
@@ -1,3 +1,16 @@
|
|
|
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
|
+
|
|
1
14
|
/**
|
|
2
15
|
* Read a package's DataModel field sets out of its source, as data.
|
|
3
16
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.2.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
package/types/config.d.mts
CHANGED
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
* @property {string} e2eStage Which stage the suite runs against.
|
|
56
56
|
* @property {Readonly<{run: readonly string[], open: readonly string[]|null}>|null} e2eSuite
|
|
57
57
|
* What to run against the served world; `null` when the repository has none.
|
|
58
|
+
* @property {readonly string[]} e2eResults Where the suite writes its results,
|
|
59
|
+
* so a run that wrote none is not mistaken for one that passed.
|
|
58
60
|
* @property {Readonly<Record<string, Readonly<{script: string, recreate: boolean}>>>} e2eBuild
|
|
59
61
|
* Build targets the fast loop can produce, in declaration order.
|
|
60
62
|
* @property {Readonly<Record<string, string>>} e2eWorld Declared world identity.
|
|
@@ -260,6 +262,11 @@ export type PackageBuildConfig = {
|
|
|
260
262
|
run: readonly string[];
|
|
261
263
|
open: readonly string[] | null;
|
|
262
264
|
}> | null;
|
|
265
|
+
/**
|
|
266
|
+
* Where the suite writes its results,
|
|
267
|
+
* so a run that wrote none is not mistaken for one that passed.
|
|
268
|
+
*/
|
|
269
|
+
e2eResults: readonly string[];
|
|
263
270
|
/**
|
|
264
271
|
* Build targets the fast loop can produce, in declaration order.
|
|
265
272
|
*/
|
package/types/e2e.d.mts
CHANGED
|
@@ -188,6 +188,106 @@ export function waitForWorld({ url, container, stage, timeoutMs, log }: {
|
|
|
188
188
|
timeoutMs?: number | undefined;
|
|
189
189
|
log?: ((message: string) => void) | undefined;
|
|
190
190
|
}): Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Every executable that must exist for a suite command to run at all.
|
|
193
|
+
*
|
|
194
|
+
* One name for a plain command, two when a package runner is standing in for a
|
|
195
|
+
* tool. This is deliberately a *reading* of the command rather than a guess:
|
|
196
|
+
* anything it cannot read reduces to the program alone, because naming the
|
|
197
|
+
* wrong missing thing would send someone after a dependency they already have.
|
|
198
|
+
*
|
|
199
|
+
* @param {readonly string[]} command - The program and its arguments.
|
|
200
|
+
* @returns {string[]} The executables to resolve, in the order to report them.
|
|
201
|
+
*/
|
|
202
|
+
export function suiteExecutables(command: readonly string[]): string[];
|
|
203
|
+
/**
|
|
204
|
+
* Find an executable the way the child process will: a path is a path, and a
|
|
205
|
+
* bare name is looked for in the repository's `node_modules/.bin` first, then
|
|
206
|
+
* along `PATH`.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} name - The program, as the command line spells it.
|
|
209
|
+
* @param {object} [opts]
|
|
210
|
+
* @param {string} [opts.cwd] - The repository root, for `node_modules/.bin`.
|
|
211
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read `PATH` from.
|
|
212
|
+
* @returns {string|null} Where it is, or `null` if it is nowhere.
|
|
213
|
+
*/
|
|
214
|
+
export function findExecutable(name: string, { cwd, env }?: {
|
|
215
|
+
cwd?: string | undefined;
|
|
216
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
217
|
+
}): string | null;
|
|
218
|
+
/**
|
|
219
|
+
* Which of a suite command's executables are not there.
|
|
220
|
+
*
|
|
221
|
+
* Asked twice per run, and the second asking is the point: an install running
|
|
222
|
+
* alongside the suite can take the runner out from under it mid-flight, which
|
|
223
|
+
* is precisely the failure that reported itself as green (#153).
|
|
224
|
+
*
|
|
225
|
+
* @param {object} opts
|
|
226
|
+
* @param {readonly string[]} opts.command - The program and its arguments.
|
|
227
|
+
* @param {string} [opts.cwd] - The repository root.
|
|
228
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
229
|
+
* @returns {string[]} The names that resolve to nothing.
|
|
230
|
+
*/
|
|
231
|
+
export function missingExecutables({ command, cwd, env }: {
|
|
232
|
+
command: readonly string[];
|
|
233
|
+
cwd?: string | undefined;
|
|
234
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
235
|
+
}): string[];
|
|
236
|
+
/**
|
|
237
|
+
* Which declared result paths the suite actually wrote to during this run.
|
|
238
|
+
*
|
|
239
|
+
* Existence is not the test. A results directory left behind by the previous
|
|
240
|
+
* run exists, and reading that as evidence would make the check agree with
|
|
241
|
+
* exactly the thing it was built to catch. What counts is a file modified since
|
|
242
|
+
* the spawn.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} opts
|
|
245
|
+
* @param {readonly string[]} opts.paths - Declared result paths, repo-relative.
|
|
246
|
+
* @param {number} opts.since - Milliseconds since the epoch, at spawn time.
|
|
247
|
+
* @param {string} [opts.cwd] - The repository root.
|
|
248
|
+
* @returns {string[]} The declared paths carrying something new.
|
|
249
|
+
*/
|
|
250
|
+
export function freshResults({ paths, since, cwd }: {
|
|
251
|
+
paths: readonly string[];
|
|
252
|
+
since: number;
|
|
253
|
+
cwd?: string | undefined;
|
|
254
|
+
}): string[];
|
|
255
|
+
/**
|
|
256
|
+
* What the harness reports for a finished suite.
|
|
257
|
+
*
|
|
258
|
+
* @typedef {object} SuiteVerdict
|
|
259
|
+
* @property {number} status The exit status to hand back.
|
|
260
|
+
* @property {string|null} message What to say about it, if anything.
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* Decide what a finished suite is worth, given what it exited with and what it
|
|
264
|
+
* left behind.
|
|
265
|
+
*
|
|
266
|
+
* The point of the e2e suite is to be *evidence*: `compatibility.verified`
|
|
267
|
+
* moves on a green run, and a sweep exists to produce a citable result. So an
|
|
268
|
+
* exit status on its own cannot call a run green, because every way of stopping
|
|
269
|
+
* a runner before it starts — a corrupt install, a missing browser, a killed
|
|
270
|
+
* process, the concurrent `npm ci` that surfaced this — produces a run that
|
|
271
|
+
* executed nothing, and nothing is not a pass (#153).
|
|
272
|
+
*
|
|
273
|
+
* This can only ever make a verdict worse. A suite that failed keeps its own
|
|
274
|
+
* status; a suite that passed on no evidence loses the claim. Never the other
|
|
275
|
+
* way round — a harness that could *upgrade* a result would be a second way to
|
|
276
|
+
* report a green that did not happen.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} opts
|
|
279
|
+
* @param {number} opts.status - What the suite process exited with.
|
|
280
|
+
* @param {readonly string[]} [opts.vanished] - Executables gone since it started.
|
|
281
|
+
* @param {readonly string[]} [opts.declared] - Result paths the repository declares.
|
|
282
|
+
* @param {readonly string[]} [opts.fresh] - Those of them it wrote to.
|
|
283
|
+
* @returns {SuiteVerdict} The status to report, and why.
|
|
284
|
+
*/
|
|
285
|
+
export function suiteVerdict({ status, vanished, declared, fresh }: {
|
|
286
|
+
status: number;
|
|
287
|
+
vanished?: readonly string[] | undefined;
|
|
288
|
+
declared?: readonly string[] | undefined;
|
|
289
|
+
fresh?: readonly string[] | undefined;
|
|
290
|
+
}): SuiteVerdict;
|
|
191
291
|
/**
|
|
192
292
|
* Run the repository's suite.
|
|
193
293
|
*
|
|
@@ -196,18 +296,32 @@ export function waitForWorld({ url, container, stage, timeoutMs, log }: {
|
|
|
196
296
|
* runner launches as plain Node, rejects its own flags, and dies with a
|
|
197
297
|
* `MODULE_NOT_FOUND` naming nothing relevant.
|
|
198
298
|
*
|
|
299
|
+
* The suite is bracketed by checks rather than trusted on its exit status,
|
|
300
|
+
* because a run that never started used to report as green (#153):
|
|
301
|
+
*
|
|
302
|
+
* - **Before.** Every executable the command needs is resolved, and a missing
|
|
303
|
+
* one is an error naming it — rather than a container stood up, a world
|
|
304
|
+
* seeded, and a failure three minutes later that names nothing.
|
|
305
|
+
* - **After.** The same question again, because the reported failure was an
|
|
306
|
+
* install pulling the runner out from under a run already in progress; and,
|
|
307
|
+
* where the repository declares where its results land, whether anything was
|
|
308
|
+
* written there while the suite ran.
|
|
309
|
+
*
|
|
199
310
|
* @param {object} opts
|
|
200
|
-
* @param {string[]} opts.command - The program and its arguments.
|
|
311
|
+
* @param {readonly string[]} opts.command - The program and its arguments.
|
|
201
312
|
* @param {string[]} [opts.args] - Extra arguments, appended verbatim.
|
|
202
313
|
* @param {string} opts.cwd - The repository root.
|
|
314
|
+
* @param {readonly string[]} [opts.results] - Declared result paths to check.
|
|
203
315
|
* @param {NodeJS.ProcessEnv} [opts.env] - Environment for the child.
|
|
204
316
|
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
205
317
|
* @returns {number} The suite's exit status.
|
|
318
|
+
* @throws {Error} When the command names an executable that is not installed.
|
|
206
319
|
*/
|
|
207
|
-
export function runSuite({ command, args, cwd, env, log }: {
|
|
208
|
-
command: string[];
|
|
320
|
+
export function runSuite({ command, args, cwd, results, env, log, }: {
|
|
321
|
+
command: readonly string[];
|
|
209
322
|
args?: string[] | undefined;
|
|
210
323
|
cwd: string;
|
|
324
|
+
results?: readonly string[] | undefined;
|
|
211
325
|
env?: NodeJS.ProcessEnv | undefined;
|
|
212
326
|
log?: ((message: string) => void) | undefined;
|
|
213
327
|
}): number;
|
|
@@ -349,3 +463,16 @@ export type FastArgs = {
|
|
|
349
463
|
*/
|
|
350
464
|
suiteArgs: string[];
|
|
351
465
|
};
|
|
466
|
+
/**
|
|
467
|
+
* What the harness reports for a finished suite.
|
|
468
|
+
*/
|
|
469
|
+
export type SuiteVerdict = {
|
|
470
|
+
/**
|
|
471
|
+
* The exit status to hand back.
|
|
472
|
+
*/
|
|
473
|
+
status: number;
|
|
474
|
+
/**
|
|
475
|
+
* What to say about it, if anything.
|
|
476
|
+
*/
|
|
477
|
+
message: string | null;
|
|
478
|
+
};
|