@heroiclands/package-build 18.1.1 → 19.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.
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Reject commit messages that carry AI/assistant attribution.
4
+ #
5
+ # This project does not want Co-Authored-By trailers naming an AI assistant, nor
6
+ # "Generated with Claude Code"-style signatures, in its history. The matching CI
7
+ # guard is the shared HeroicLands/.github/actions/no-attribution action, which
8
+ # this repository's .github/workflows/no-attribution.yml calls. That action holds
9
+ # the pattern for every repository; this hook is the only other copy, because a
10
+ # git hook runs from your checkout and cannot live in an action. Keep them in sync.
11
+ #
12
+ # Installed for everyone via the package.json "prepare" script, which points
13
+ # git at this directory (`git config core.hooksPath .githooks`) on `npm install`.
14
+
15
+ . "$(dirname "$0")/hook-enabled.sh"
16
+
17
+ # On unless refused: it costs nothing, and the No Attribution workflow enforces
18
+ # the same rule on GitHub, so opting out locally only moves the failure later.
19
+ hook_enabled noAttribution true || exit 0
20
+
21
+ msg_file="$1"
22
+
23
+ # Scan only the real message: drop the diff appended by `commit --verbose` (from
24
+ # the scissors comment onward) and strip remaining comment lines.
25
+ content="$(sed '/^#.*>8/,$d' "$msg_file" | grep -v '^#')"
26
+
27
+ # Anchored to the start of a line: real attribution is a trailer / signature at
28
+ # column 0, so prose that merely *mentions* these phrases mid-sentence (e.g. this
29
+ # hook's own docs) does not trip it. The generated-with branch tolerates a
30
+ # leading emoji/space.
31
+ pattern='^[[:space:]]*co-authored-by:.*(claude|anthropic)|^[^[:alpha:]]*generated with .*claude code'
32
+
33
+ if printf '%s\n' "$content" | grep -iEq "$pattern"; then
34
+ echo "commit-msg: AI/assistant attribution is not allowed in commit messages." >&2
35
+ printf '%s\n' "$content" | grep -iEn "$pattern" | sed 's/^/ /' >&2
36
+ echo "Remove the 'Co-Authored-By: …' / 'Generated with Claude Code' line and commit again." >&2
37
+ exit 1
38
+ fi
39
+
40
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Whether one hook is switched on, so every hook asks the same way.
4
+ #
5
+ # `hook_enabled <key> <default>` reads `hooks.<key>` with git's normal
6
+ # precedence — a plain `git config` sets it for one clone, `--global` for a
7
+ # machine — and falls back to the hook's own default when unset. Each hook has
8
+ # its own key, so they are turned on and off individually rather than as a set:
9
+ # a repository may well want the branch guard and not the workflow check, or
10
+ # the reverse.
11
+ #
12
+ # Defaults differ by hook, and deliberately: a guard that costs nothing is on
13
+ # unless refused, while one that runs a container for minutes is off unless
14
+ # asked for.
15
+
16
+ hook_enabled() {
17
+ _key="$1"
18
+ _default="$2"
19
+ _value="$(git config --bool "hooks.$_key" 2>/dev/null)"
20
+ if [ -z "$_value" ]; then
21
+ [ "$_default" = "true" ]
22
+ return $?
23
+ fi
24
+ [ "$_value" = "true" ]
25
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Refuse to commit on a protected branch. See protected-branch.sh for the rule
4
+ # and its opt-outs; pre-merge-commit is this hook's counterpart for merges.
5
+ #
6
+ # Installed for everyone via the package.json "prepare" script, which points git
7
+ # at this directory (`git config core.hooksPath .githooks`) on `npm install`.
8
+
9
+ . "$(dirname "$0")/protected-branch.sh"
10
+
11
+ guard_protected_branch
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Refuse to create a *merge* commit on a protected branch — the `git pull` on
4
+ # main case, which pre-commit never sees, because git runs this hook instead of
5
+ # that one for a merge. See protected-branch.sh for the rule and its opt-outs.
6
+ #
7
+ # Installed for everyone via the package.json "prepare" script, which points git
8
+ # at this directory (`git config core.hooksPath .githooks`) on `npm install`.
9
+
10
+ . "$(dirname "$0")/protected-branch.sh"
11
+
12
+ guard_protected_branch
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Before pushing, run exactly what the Build & Test workflow runs — in a
4
+ # container, over a clean export of HEAD — so a red GitHub check is something
5
+ # you chose rather than something you discover.
6
+ #
7
+ # The steps are not listed here. `utils/ci-steps.mjs` reads them out of
8
+ # `.github/workflows/build.yml`, because a hook holding its own copy of the
9
+ # command list is a second statement of one thing, and the copy is the one that
10
+ # goes stale — silently, since running four of five steps still exits 0.
11
+ #
12
+ # **On push rather than at `gh pr create`**, because a push is where this
13
+ # repository's work reaches GitHub: normally one, just before the pull request.
14
+ # Nothing to remember, no wrapper command, and no entry in package.json — the
15
+ # hook is the whole mechanism.
16
+ #
17
+ # **In a container rather than on this machine**, which is not the obvious
18
+ # choice until you time it. The workflow's first step is `npm ci`, so a local
19
+ # run costs very nearly what the container costs (134s measured, cold) — and it
20
+ # pays that by deleting and reinstalling your working tree's `node_modules`
21
+ # every time. The container runs a `git archive` of HEAD instead: same price,
22
+ # nothing of yours touched, plus the three classes this machine cannot catch —
23
+ # a dirty environment, a case-sensitive filesystem, and the runner's own
24
+ # architecture. It runs `linux/amd64` to match the runner, which costs nothing
25
+ # measurable because Docker Desktop translates it with Rosetta rather than
26
+ # QEMU; `ci/ci-docker.mjs` carries the timings and the `--native` opt-out.
27
+ #
28
+ # This hook ships in `@heroiclands/package-build`. A consuming repository points
29
+ # git at it once, in its package.json "prepare" script:
30
+ #
31
+ # git config core.hooksPath node_modules/@heroiclands/package-build/githooks
32
+ #
33
+ # so the hooks are the package's, not a copy per repository — there were twenty
34
+ # copies of four identical files before this moved.
35
+ #
36
+ # **Off by default.** Turn it on where you want it:
37
+ #
38
+ # git config hooks.prePushCi true this clone
39
+ # git config --global hooks.prePushCi true every repository on this machine
40
+ #
41
+ # **Docker is not required even then.** Without it the check reports that it
42
+ # could not run, says so loudly, and lets the push through — the workflow itself
43
+ # is what enforces this, and a contributor without Docker is still entitled to
44
+ # open a pull request. First run pulls the image (~400MB), once.
45
+ #
46
+ # Skipping it once, when enabled: git push --no-verify
47
+ #
48
+ # Deleting a remote branch pushes no commits, so there is nothing to check and
49
+ # the hook stands aside.
50
+
51
+ # **Off unless asked for.** This runs a container for a couple of minutes, and
52
+ # it ships to every repository that installs this package — so it cannot be
53
+ # something a contributor discovers by having their push get slow. Opting in is
54
+ # a deliberate act by whoever wants the check.
55
+ #
56
+ # Read with git's normal precedence, so `--global` opts a machine in and a plain
57
+ # `git config` opts in one clone (and every worktree of it).
58
+ #
59
+ # Silent when off: a message on every push, to everyone who never asked for
60
+ # this, is noise.
61
+ . "$(dirname "$0")/hook-enabled.sh"
62
+
63
+ hook_enabled prePushCi false || exit 0
64
+
65
+ # Read the ref list on stdin; a delete has an all-zero local sha.
66
+ has_commits=""
67
+ while read -r _local_ref local_sha _remote_ref _remote_sha; do
68
+ case "$local_sha" in
69
+ *[!0]*) has_commits="yes" ;;
70
+ esac
71
+ done
72
+ if [ -z "$has_commits" ]; then
73
+ exit 0
74
+ fi
75
+
76
+ echo "pre-push: running the Build & Test workflow in a container over HEAD."
77
+ echo "pre-push: to skip once, push with --no-verify."
78
+
79
+ # The runner lives beside this hook inside the package. If it is not there,
80
+ # this hook is being used from somewhere else — a global `core.hooksPath`, a
81
+ # copy — in a repository that does not install the package. Nothing to run, and
82
+ # certainly nothing to refuse a push over.
83
+ runner="$(dirname "$0")/../ci/ci-docker.mjs"
84
+ if [ ! -f "$runner" ]; then
85
+ echo "pre-push: no workflow runner alongside this hook; skipping the check."
86
+ exit 0
87
+ fi
88
+
89
+ node "$runner"
90
+ status=$?
91
+
92
+ # 2 means the check could not run — no Docker — which is not the same as a
93
+ # failure and must not stop the push. Whoever is pushing may not be the
94
+ # maintainer, may be on a machine without Docker, and is entitled to open a
95
+ # pull request; GitHub is what enforces the workflow. Said loudly, though: a
96
+ # skipped check that reads like a passed one is how a guard loses its meaning.
97
+ # 127 is "could not execute it at all" — no node on PATH, package not
98
+ # installed. Infrastructure, not a verdict, so it is handled the same way.
99
+ if [ "$status" -eq 2 ] || [ "$status" -eq 127 ]; then
100
+ echo ""
101
+ echo "pre-push: pushing WITHOUT having checked the workflow locally."
102
+ exit 0
103
+ fi
104
+
105
+ if [ "$status" -ne 0 ]; then
106
+ echo ""
107
+ echo "pre-push: refusing to push — GitHub would report the same failure."
108
+ exit 1
109
+ fi
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env sh
2
+ #
3
+ # Shared guard: refuse to create a commit while HEAD is on a protected branch.
4
+ #
5
+ # Sourced by the pre-commit and pre-merge-commit hooks, which are two hooks for
6
+ # one rule: git runs pre-merge-commit *instead of* pre-commit when the commit is
7
+ # a merge, so a repository that guards only pre-commit still lets a stray
8
+ # `git pull` on main write a merge commit.
9
+ #
10
+ # Why guard at commit time at all: `main` is protected on GitHub in every
11
+ # HeroicLands repository — changes land by pull request, squash-merged, never by
12
+ # a direct push. That protection fires at *push* time, by which point the commit
13
+ # already exists on the local branch and has to be moved off it. This moves the
14
+ # refusal forward to the point where the fix is still just "branch first".
15
+ #
16
+ # It is an accident guard, not a security control. `git commit --no-verify`
17
+ # bypasses it, and a repository that genuinely wants commits on its default
18
+ # branch opts out with `git config hooks.allowCommitOnMain true`.
19
+ #
20
+ # Known gap: `git cherry-pick` and `git revert` run neither hook, and a rebase
21
+ # replays commits with HEAD detached (deliberately allowed below).
22
+ #
23
+ # This file is copied verbatim into every HeroicLands repository, for the same
24
+ # reason the commit-msg hook is: a hook runs from your checkout, so it cannot be
25
+ # shared through a GitHub Action. Keep the copies identical.
26
+
27
+ guard_protected_branch() {
28
+ . "$(dirname "$0")/hook-enabled.sh"
29
+
30
+ # Off where this repository says so. Two spellings: `hooks.protectedBranch`
31
+ # is this hook's key in the per-hook scheme, and `hooks.allowCommitOnMain`
32
+ # is the name that has always meant this — kept, and still honoured, so an
33
+ # existing opt-out does not quietly stop working.
34
+ if ! hook_enabled protectedBranch true; then
35
+ return 0
36
+ fi
37
+ if [ "$(git config --bool hooks.allowCommitOnMain 2>/dev/null)" = "true" ]; then
38
+ return 0
39
+ fi
40
+
41
+ # Detached HEAD — a rebase, a bisect, or an explicit checkout of a commit.
42
+ # There is no branch to protect, and refusing here would break `git rebase`.
43
+ branch="$(git symbolic-ref --quiet --short HEAD)" || return 0
44
+
45
+ case "$branch" in
46
+ main | master) ;;
47
+ *) return 0 ;;
48
+ esac
49
+
50
+ cat >&2 <<EOF
51
+ $(basename "$0"): refusing to commit on the protected branch '$branch'.
52
+
53
+ '$branch' is protected on GitHub, so this commit could never be pushed from
54
+ here. Move it onto a branch first — this keeps everything you have staged:
55
+
56
+ git switch -c <type>/<issue_#>_<slug>
57
+
58
+ To commit here anyway just this once, use 'git commit --no-verify'. To opt this
59
+ repository out permanently, 'git config hooks.allowCommitOnMain true'.
60
+ EOF
61
+ return 1
62
+ }
package/hm3/actors.mjs CHANGED
@@ -259,16 +259,13 @@ export class Hm3Actors extends SystemActorCompiler {
259
259
  locateFrontmatterKey(this.currentNote?.absPath, field.legacyKey),
260
260
  );
261
261
 
262
- // Both spellings, as everywhere else: `packFolder` is a folder note's
263
- // address and `folder` a Foundry id, and which one applies comes from
264
- // the field it was written in rather than from the string (#251, #255).
265
- // This pass read only the id, so an HM3 tree could not file an actor by
266
- // address at all which its own sweep needs.
267
- const packFolderAddress = blockField(fm, block, "packFolder", null);
268
- const folder =
269
- packFolderAddress ?
270
- this.folderResolver(packFolderAddress, { isAddress: true })
271
- : this.folderResolver(blockField(fm, block, "folder", null));
262
+ // One spelling, as everywhere else: `packFolder` names a folder note
263
+ // by its address. This pass once read only the Foundry id, so an HM3
264
+ // tree could not file an actor by address at all which its own sweep
265
+ // needs; the id spelling is retired outright (#251, #255, #260).
266
+ const folder = this.folderResolver(blockField(fm, block, "packFolder", null), {
267
+ isAddress: true,
268
+ });
272
269
 
273
270
  const system = {
274
271
  // Nullish, not `||` (#218): a note that names no portrait gets the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "18.1.1",
3
+ "version": "19.0.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",
@@ -105,6 +105,7 @@
105
105
  "README.md",
106
106
  "bin",
107
107
  "bundle.mjs",
108
+ "ci",
108
109
  "config.mjs",
109
110
  "container.mjs",
110
111
  "content-config.mjs",
@@ -113,6 +114,7 @@
113
114
  "deploy.mjs",
114
115
  "e2e.mjs",
115
116
  "engine",
117
+ "githooks",
116
118
  "hm3",
117
119
  "index.mjs",
118
120
  "lang.mjs",
@@ -214,12 +214,6 @@ export type PackSpec = {
214
214
  * Whether the pack is GM-only. Default `false`.
215
215
  */
216
216
  private?: boolean | undefined;
217
- /**
218
- * The pack's folder-hierarchy file, relative
219
- * to `paths.content`. Default `null` — no
220
- * folder documents are emitted.
221
- */
222
- folders?: string | null | undefined;
223
217
  /**
224
218
  * Directory holding this pack's per-document
225
219
  * JSON, already built. Declaring it skips
@@ -268,7 +262,6 @@ export type ResolvedPackSpec = {
268
262
  type: PackDocumentType;
269
263
  label: string;
270
264
  private: boolean;
271
- folders: string | null;
272
265
  prebuilt: string | null;
273
266
  system: string | null;
274
267
  companions: readonly Readonly<ResolvedPackSpec>[];
@@ -77,6 +77,45 @@ export function buildFolderNoteIndex(folders: FolderNote[]): {
77
77
  * @returns {object} The Folder document.
78
78
  */
79
79
  export function folderDocument(folder: FolderNote, parent: FolderNote | null, documentType: string, stats: object): object;
80
+ /**
81
+ * Refuse a note that declares the retired `folder:` spelling.
82
+ *
83
+ * `folder:` named a compendium folder by the raw Foundry id declared in a
84
+ * per-pack `*-folders.yaml`. Both halves are retired together (#260): the id
85
+ * spelling has nothing left to resolve against once the YAML is gone, and the
86
+ * YAML has no reader once the spelling is refused.
87
+ *
88
+ * **Presence is the whole test.** An empty `folder:` — which parses as `null`
89
+ * — is still the field, and a value that happens to match a folder note's id
90
+ * is still the retired spelling. There is no value that makes writing it
91
+ * correct, so the message says what to write instead rather than which value
92
+ * to change.
93
+ *
94
+ * **Both positions**, because notes wrote it both ways: top-level, and inside
95
+ * the `sohl:` block. Checking only the more common one is how a sweep leaves
96
+ * a tail behind.
97
+ *
98
+ * Refused rather than ignored, on the pattern `package:` set: a retired field
99
+ * left ignored reads to its author as though it still works — the note says
100
+ * one thing and the build does another, and nothing says so.
101
+ *
102
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
103
+ * could not be parsed.
104
+ * @param {object} [options] - Options.
105
+ * @param {string} [options.file] - The note's path, named in the message. Omit
106
+ * it where the caller emits through a diagnostic, which puts the locator at
107
+ * the start of the line already — repeating it prints the path twice.
108
+ * @param {string} [options.absPath] - The note's file on disk, read only on
109
+ * the failing path to locate the offending line and column. The position
110
+ * rides on the thrown error as `position`, for a caller that emits a
111
+ * diagnostic.
112
+ * @returns {void}
113
+ * @throws {Error} When the note declares the field.
114
+ */
115
+ export function assertNoDeclaredFolder(fm: object | null | undefined, { file, absPath }?: {
116
+ file?: string | undefined;
117
+ absPath?: string | undefined;
118
+ }): void;
80
119
  /**
81
120
  * The note type a folder is authored as.
82
121
  *
@@ -428,41 +428,12 @@ export function expandNoteTables(body: string, { docs, name, fm, bodyLine, sqlTa
428
428
  generated: boolean;
429
429
  }>;
430
430
  };
431
- /**
432
- * Loads a folders.yaml file as an array of folder entries. Returns []
433
- * when the file is missing (logging a warning) so packs without folders
434
- * can opt out simply by not committing the file.
435
- */
436
- export function loadFolders(foldersFile: any): any[];
437
- /**
438
- * Validates folder invariants and returns a resolver function that maps a
439
- * folder id to the same id (after verifying it exists). Returns `null` for
440
- * a null/empty input; throws for an unknown id.
441
- *
442
- * Invariants:
443
- * - Every folder must have a non-empty id
444
- * - Every folder must have a name
445
- * - Sibling folders (same parentFolderId) must have unique names
446
- * - Every parentFolderId must match an existing folder id (or be "")
447
- *
448
- * Returns { resolver, folders } where folders is the validated list.
449
- */
450
- export function buildFolderResolver(folders: any): {
451
- resolver: (value: string | null | undefined) => string | null;
452
- folders: any;
453
- };
454
431
  /**
455
432
  * Builds a compendium-source filename for a folder JSON document:
456
433
  * `folder_Name_id.json` with non-alphanumeric runs replaced by
457
434
  * underscores.
458
435
  */
459
436
  export function folderFilename(name: any, id: any): string;
460
- /**
461
- * Writes one JSON document per folder into `destDir`. `documentType`
462
- * determines the folder's Foundry `type` field — `"Item"` for the items
463
- * pack, `"JournalEntry"` for the journals pack.
464
- */
465
- export function writeFolderDocs(folders: any, stats: any, destDir: any, documentType: any): void;
466
437
  export const md: import("markdown-it").MarkdownIt;
467
438
  export { slugify } from "./content-slug.mjs";
468
439
  export { makeId } from "./ids.mjs";