@heroiclands/package-build 23.7.1 → 23.8.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +32 -0
  3. package/bin/content-commands.mjs +20 -6
  4. package/ci/check-banners.mjs +37 -19
  5. package/container.mjs +48 -10
  6. package/docs/api.md +87 -43
  7. package/docs/authoring/frontmatter.md +30 -1
  8. package/docs/authoring/links-and-markup.md +14 -0
  9. package/docs/commands.md +12 -2
  10. package/docs/content-format.md +23 -0
  11. package/docs/design.md +31 -0
  12. package/docs/guides/e2e.md +37 -1
  13. package/docs/project-setup.md +30 -15
  14. package/docs/reference/format-details.md +44 -9
  15. package/docs/reference/note-types.md +7 -4
  16. package/e2e.mjs +89 -2
  17. package/engine/base-compiler.mjs +11 -18
  18. package/engine/compile-corpus.mjs +2 -1
  19. package/engine/content-alerts.mjs +4 -2
  20. package/engine/content-blocks.mjs +18 -36
  21. package/engine/content-index.mjs +35 -22
  22. package/engine/content-poetry.mjs +9 -0
  23. package/engine/diagnostics.mjs +24 -11
  24. package/engine/frontmatter-lint.mjs +1 -1
  25. package/engine/generate.mjs +12 -22
  26. package/engine/helpers.mjs +8 -6
  27. package/engine/infobox.mjs +3 -0
  28. package/engine/journals.mjs +11 -1
  29. package/engine/markdownlint-run.mjs +12 -2
  30. package/engine/markup-validation.mjs +34 -0
  31. package/engine/note-addresses.mjs +1 -0
  32. package/engine/note-claims.mjs +19 -10
  33. package/engine/note-package.mjs +3 -5
  34. package/engine/note-vocabulary.mjs +29 -1
  35. package/engine/pdf-build.mjs +8 -1
  36. package/engine/prose-lint.mjs +12 -6
  37. package/engine/related-pages.mjs +22 -3
  38. package/engine/see-also.mjs +150 -0
  39. package/engine/site-build.mjs +5 -1
  40. package/engine/site-preview.mjs +4 -2
  41. package/engine/sql-tables.mjs +25 -10
  42. package/engine/standings.mjs +14 -12
  43. package/hugo-theme/layouts/partials/related.html +25 -8
  44. package/package.json +2 -1
  45. package/types/container.d.mts +4 -3
  46. package/types/e2e.d.mts +28 -0
  47. package/types/engine/base-compiler.d.mts +2 -2
  48. package/types/engine/compile-corpus.d.mts +2 -1
  49. package/types/engine/content-index.d.mts +20 -7
  50. package/types/engine/diagnostics.d.mts +7 -6
  51. package/types/engine/helpers.d.mts +7 -4
  52. package/types/engine/markdownlint-run.d.mts +3 -1
  53. package/types/engine/markup-validation.d.mts +14 -0
  54. package/types/engine/note-addresses.d.mts +10 -0
  55. package/types/engine/note-claims.d.mts +7 -1
  56. package/types/engine/note-package.d.mts +3 -1
  57. package/types/engine/note-vocabulary.d.mts +1 -0
  58. package/types/engine/related-pages.d.mts +10 -1
  59. package/types/engine/see-also.d.mts +55 -0
  60. package/types/engine/sql-tables.d.mts +7 -3
package/docs/api.md CHANGED
@@ -348,6 +348,20 @@ An addressed content note accepts exactly these top-level keys, in this order: `
348
348
  | `dataFields` | `dataFields(type, vocabulary)` | `readonly DataFieldSpec[] \| undefined` | looking up the `data:` keys a note type may carry |
349
349
  | `subTypes` | `subTypes(type, vocabulary)` | `readonly string[] \| null \| undefined` | looking up the closed `subType` values a note type declares |
350
350
 
351
+ ### `./engine/see-also`
352
+
353
+ Import `@heroiclands/package-build/engine/see-also` for the deliberate
354
+ cross-references declared by `data.seeAlso`. Entries preserve authored order;
355
+ the caller supplies the medium's reference resolver and link renderer.
356
+
357
+ | Export | Signature | Returns | Use it when |
358
+ | ----------------- | ----------------------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
359
+ | `checkSeeAlso` | `checkSeeAlso(note, { index, addressContext })` | `object[]` | locating malformed, unresolved, duplicate or self references |
360
+ | `seeAlsoEntries` | `seeAlsoEntries(fm, resolve)` | `object[]` | resolving names and destinations with optional glosses |
361
+ | `seeAlsoMarkdown` | `seeAlsoMarkdown(entries, link, level = 2)` | `string` | emitting an ordered See also section, empty when no entries resolve |
362
+ | `seeAlsoWebLink` | `seeAlsoWebLink(entry)` | `string` | writing a resolved book reference as a Markdown link |
363
+ | `seeAlsoPages` | `seeAlsoPages(pages, resolve)` | `Map<string, RelatedEntry[]>` | deriving ordered site connections for both builds and live previews |
364
+
351
365
  ### `engine.infobox`
352
366
 
353
367
  The declared infobox: what a note's summary panel holds, decided once and rendered by each medium. The note box's fields are the type's own `data:` vocabulary, in its declared order, so a key added to a type appears everywhere with no second edit; the overlay declares only the label and the handful of keys that carry no row.
@@ -860,19 +874,19 @@ Emitting this package's cross-package link manifest. `engine/content-address.mjs
860
874
 
861
875
  Emitting this package's content index. Every content build already walks the whole note tree and parses every note's frontmatter — the pack compilers, the site build, and the content-table expander each do it — and every one of them throws the result away when it finishes. So nothing outside a build can ask a question about the content: "which beings carry no `kbcat`?", "what does this table actually select?", "did that type rename leave anything behind?" have no answer short of writing a throwaway script that re-walks the tree. Eight dead Bestiary tables shipped for weeks behind exactly that gap.
862
876
 
863
- | Export | Signature | Returns | Use it when |
864
- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
865
- | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
866
- | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
867
- | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
868
- | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
869
- | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
870
- | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
871
- | `indexRecordsForNote` | `indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, contentPackage, manifest, addressContext })` | `object[]` | Derive the note's own record and its documentation record from parsed text. |
872
- | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config })` | `{{file: string, notes: number, assets: number, records: number, bytes: number, full: number, draft: number, stub: number}}` Where it was written, how many notes and how many assets it holds, how many records that is in all, its size, and the ladder those notes sit on. | Emit this package's content index. |
873
- | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
874
- | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems, foreignRecord })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file; each note carries its resolved culture as `data.culture` |
875
- | `resolveCultures` | `resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems })` | `Array<Record<string, any>>` — the same records | writing each note's resolved culture into its record as `data.culture`: its own value, else its parents', else for a polity its seat's and governed places', pooled; parents that disagree give a finding into `problems`, or one thrown Error listing every conflict |
877
+ | Export | Signature | Returns | Use it when |
878
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
879
+ | `noteAddress` | `function noteAddress(frontmatter, contentPackage)` | {{slug: string, canonical: string}\|null} `slug` is what goes inside `[[…]]` within this package; `canonical` is the fully qualified key the manifest files the note under, carrying the package and the system as well. | The address a wikilink writes to reach a note, or `null` when it has none. |
880
+ | `sortKeysDeep` | `function sortKeysDeep(value)` | {unknown} The value with every plain object's keys in sorted order. | Recursively sort an object's keys, so serialization is order-independent. |
881
+ | `asciiName` | `function asciiName(name)` | {string\|null} The ASCII form, or `null` when there is no name, or nothing printable survives. | A note's display name reduced to printable 7-bit ASCII. |
882
+ | `asciiAliases` | `function asciiAliases(aliases)` | {Array<string>} Possibly empty, never null: a note with no aliases has an empty set of them, which is a fact rather than a missing value, and a consumer iterating it should not have to check first. | A note's `name.aliases` reduced to printable 7-bit ASCII, in order. |
883
+ | `serializeContentIndex` | `function serializeContentIndex(records)` | {string} One compact JSON object per line, newline-terminated. | Serialize records as JSON Lines. |
884
+ | `indexRecordsFor` | `indexRecordsFor({ contentBase, config, skipDirectories, problems, parsed })` | {object[]} One record per note, plus one per documentation entry. | The index records for a content tree, without writing anything. |
885
+ | `indexRecordsForNote` | `indexRecordsForNote({ frontmatter, body, bodyLine, relPath, absPath, raw, contentPackage, manifest, addressContext })` | `object[]` | Derive the note's own record and its documentation record from parsed text. |
886
+ | `emitContentIndex` | `emitContentIndex({ contentBase, outDir, config })` | `{{file: string, notes: number, assets: number, records: number, bytes: number, full: number, draft: number, stub: number}}` Where it was written, how many notes and how many assets it holds, how many records that is in all, its size, and the ladder those notes sit on. | Emit this package's content index. |
887
+ | `buildIndexRecord` | `buildIndexRecord({ frontmatter, relPath, absPath, raw, contentPackage, body, bodyLine, manifest })` | `Record<string, any>` — the record, with derived fields sorted deep | building one note's content-index record; refuses a note that authors a key the index derives itself (`package` among them) before building anything |
888
+ | `collectContentIndex` | `collectContentIndex(contentBase, { contentPackage, skipDirectories, assetsBase, manifest, problems, foreignRecord, parsed })` | `Array<Record<string, any>>` | reading a whole content tree into index records, in a total order independent of directory-read order; an item note yields two records (its own and its documentation journal's), and each asset root yields one per file; each note carries its resolved culture as `data.culture` |
889
+ | `resolveCultures` | `resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems })` | `Array<Record<string, any>>` — the same records | writing each note's resolved culture into its record as `data.culture`: its own value, else its parents', else for a polity its seat's and governed places', pooled; parents that disagree give a finding into `problems`, or one thrown Error listing every conflict |
876
890
 
877
891
  `engine.contentIndex` also re-exports eight names from neighbouring leaf modules, at this same import path:
878
892
 
@@ -1197,7 +1211,7 @@ Shared helpers for the pack compilers in `packages/package-build/`. The HeroicLa
1197
1211
  | ------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1198
1212
  | `md` | `const md` | — | The markdown renderer every surface shares. |
1199
1213
  | `renderFoundryMarkdown` | `function renderFoundryMarkdown(body)` | `string` — HTML with secret sections for GM passages. | Rendering a note body for a Foundry document. |
1200
- | `parseMarkdownFile` | `function parseMarkdownFile(filePath)` | — | Parses a markdown file with YAML frontmatter. |
1214
+ | `parseMarkdownFile` | `function parseMarkdownFile(filePath)` | — | Parses a markdown file with YAML frontmatter; returns frontmatter, body, rendered description, raw source, and body coordinates. |
1201
1215
  | `assertStatedScope` | `function assertStatedScope(skipDirectories, who)` | {void} | Refuse a corpus read whose scope its caller did not state. |
1202
1216
  | `assertSuppliedCorpus` | `function assertSuppliedCorpus(records, who)` | {void} | Refuse a corpus read whose records its caller did not supply. |
1203
1217
  | `statedTemplatePriority` | `function statedTemplatePriority(fm, label,` | {number\|null} The priority, or `null` when the note is not a template or states nothing. | The template priority a note states, for a system that treats an unstated one as "not a template" rather than as an authoring error. |
@@ -1214,7 +1228,7 @@ Shared helpers for the pack compilers in `packages/package-build/`. The HeroicLa
1214
1228
  | `convertNoteWikilinks` | `convertNoteWikilinks(body, { type, id, pack, docPack, index, name, file, bodyLine, bodyColumn, lineMap })` | `{{markdown: string, unresolved: Array<object>}}` | Converts the wikilinks in one note's markdown, reporting any that have no target in the content tree. |
1215
1229
  | `expandNoteTables` | `function expandNoteTables(body,` | `{{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}` The body with every table expanded, and where each emitted line came from — which is what lets a diagnostic about the expanded body name an authored position. | Expand prepared SQL tables in one note's markdown, before wikilinks are resolved — so a generated cell may itself be a wikilink. |
1216
1230
  | `folderFilename` | `function folderFilename(name, id)` | — | Builds a compendium-source filename for a folder JSON document: `folder_Name_id.json` with non-alphanumeric runs replaced by underscores. |
1217
- | `walkMarkdownTree` | `walkMarkdownTree(rootDir, { skipDirectories })` | generator yielding `{frontmatter, body, description, bodyLine?, bodyColumn?, file, absPath}` | walking a content tree and reading every markdown file's frontmatter and body; `skipDirectories` is required rather than defaulted, so two callers cannot silently disagree about the corpus's scope |
1231
+ | `walkMarkdownTree` | `walkMarkdownTree(rootDir, { skipDirectories })` | generator yielding `{frontmatter, body, description, raw, bodyLine?, bodyColumn?, file, absPath}` | walking a content tree and reading every markdown file's frontmatter and body; `skipDirectories` is required rather than defaulted, so two callers cannot silently disagree about the corpus's scope |
1218
1232
 
1219
1233
  ### `engine.itemRegistry`
1220
1234
 
@@ -1807,6 +1821,12 @@ console.log(dataEnvVar("dev"));
1807
1821
  | `removeContainer` | `removeContainer(name, log = () => {})` | — | stopping and removing a container, tolerating "not running" and "no such container" |
1808
1822
  | `containerAction` | `containerAction({ action, stage, config, env, log })` | `number` | performing one `CONTAINER_ACTIONS` action for a stage end to end |
1809
1823
 
1824
+ `ensureS3` requires readable regular configuration files. Reuse requires an
1825
+ inspectable identities bind pointing to the current file; an incompatible bind
1826
+ throws with a scoped recovery command and leaves the server and bucket data
1827
+ intact. Both new and reused servers must answer their S3 HTTP health endpoint
1828
+ before the bucket is ensured.
1829
+
1810
1830
  ## `./coverage`
1811
1831
 
1812
1832
  Whether the localization keys a package **references** and the keys it **declares** are the same set. A referenced-but-undeclared key is an error — it renders as its raw key string to a player; a declared-but-unreferenced key is advisory only, since no scan can see every way a key might be reached. Generic Foundry reference shapes (`{{localize}}`, `game.i18n.localize`, `LOCALIZATION_PREFIXES`, template literals) are read here directly; a repository that mints keys by its own convention contributes a named module through configuration. Everything exported is pure — source text in, references or findings out.
@@ -1854,7 +1874,7 @@ console.log(parseRemote("user@example.org:/srv/foundry"));
1854
1874
 
1855
1875
  ## `./e2e`
1856
1876
 
1857
- The end-to-end harness: a disposable Foundry world, served from a container, with a browser suite driven against it. The harness does not know what the suite is — what runs against the served world is named in `packageBuild.e2e.suite` — it only stands the licensed Foundry up, seeds a world with a known Gamemaster password, waits for that world to be _active_, and tears it down again. Three shapes of run answer different questions: `run` (from scratch, the only path that may change Foundry build), `fast` (the iteration loop — rebuild, redeploy, cycle, re-run), and `sweep` (the same full run against a build the repository does not pin, so `compatibility.verified` can be evidence rather than hope).
1877
+ The end-to-end harness: a disposable Foundry world, served from a container, with a browser suite driven against it. The suite is named in `packageBuild.e2e.suite`. For recognizable Cypress commands, the harness supplies pinned Chrome for Testing unless the arguments select a browser. It stands the licensed Foundry up, seeds a world with a known Gamemaster password, waits for that world to be _active_, and tears it down again. Three shapes of run answer different questions: `run` (from scratch, the only path that may change Foundry build), `fast` (the iteration loop — rebuild, redeploy, cycle, re-run), and `sweep` (the same full run against a build the repository does not pin, so `compatibility.verified` can be evidence rather than hope).
1858
1878
 
1859
1879
  ```js
1860
1880
  import { hashPassword, E2E_MODES } from "@heroiclands/package-build/e2e";
@@ -1865,33 +1885,36 @@ console.log(E2E_MODES);
1865
1885
  // -> ["run", "open"]
1866
1886
  ```
1867
1887
 
1868
- | Export | Signature | Returns | Use it when |
1869
- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1870
- | `E2E_GM_ID` | `const E2E_GM_ID` | — | reading the fixed Gamemaster document id every seeded world carries |
1871
- | `E2E_SCENE_ID` | `const E2E_SCENE_ID` | — | reading the seeded, pre-activated default scene's id |
1872
- | `E2E_MODES` | `const E2E_MODES` | — | the closed set of modes the suite may run under (`"run"` / `"open"`) |
1873
- | `hashPassword` | `hashPassword(password, salt)` | `string` — the hex hash | hashing a password the way Foundry's own `core/auth.mjs` does, to seed a login that actually works |
1874
- | `resolveE2EWorld` | `resolveE2EWorld(config, env = process.env)` | `E2EWorld` | resolving the seeded world's identity from configuration, with an environment override under the repository's own variable prefix |
1875
- | `worldManifest` | `worldManifest({ worldId, worldTitle, worldDescription, systemId, systemVersion, coreVersion })` | `object` — the world manifest | building the `world.json` a seeded world carries |
1876
- | `gmDocument` | `gmDocument({ id, name, password, salt })` | `object` — the user document | building the seeded world's single Gamemaster user document |
1877
- | `defaultSceneDocument` | `defaultSceneDocument()` | `object` — the scene document | building the one pre-activated scene every seeded world carries |
1878
- | `moduleConfigurationDocument` | `moduleConfigurationDocument(packageId)` | `object` — the setting document | building the world setting that switches a module package on (a module must be activated; a system does not) |
1879
- | `isWorldActive` | `isWorldActive(body)` | `boolean` | checking whether a `/join` response body shows a world that is actually active, not merely reachable |
1880
- | `resolveSweepVersion` | `resolveSweepVersion(argv)` | `string` — the exact build, trimmed | reading the exact Foundry build a sweep was asked to run against; there is deliberately no default |
1881
- | `parseFastArgs` | `parseFastArgs(argv, build)` | `FastArgs` | parsing the fast loop's CLI arguments against the repository's declared build table, in declaration order |
1882
- | `seedTestWorld` | `async seedTestWorld({ config, packageJson, env = process.env, log = () => {}, runScript })` | `Promise<{worldDir: string, world: E2EWorld}>` | seeding the disposable world into the end-to-end stage's data root, replacing the world directory through a throwaway container so the Foundry container's user owns it |
1883
- | `deployForContainer` | `async deployForContainer({ config, env = process.env, log = () => {}, runScript })` | `Promise<void>` | deploying the staged package to the end-to-end stage, removing the old install and handing the new one to the Foundry container's user through a throwaway container |
1884
- | `waitForWorld` | `async waitForWorld({ url, container, stage, timeoutMs = 180_000, log = () => {} })` | `Promise<void>` | polling until a seeded world is active, or failing with a diagnosis (a licence failure is detected from the container log, not a timeout) |
1885
- | `suiteExecutables` | `suiteExecutables(command)` | `string[]` | reading every executable a suite command needs to exist before it can run at all |
1886
- | `findExecutable` | `findExecutable(name, { cwd, env = process.env } = {})` | `string\|null` | finding an executable the way the child process would (`node_modules/.bin` first, then `PATH`) |
1887
- | `missingExecutables` | `missingExecutables({ command, cwd, env = process.env })` | `string[]` | checking which of a suite command's executables are not there — asked again mid-run, since an install can pull the runner out from under a run in progress |
1888
- | `freshResults` | `freshResults({ paths, since, cwd })` | `string[]` | finding which declared result paths a suite run actually wrote to since it was spawned (existence alone is not evidence) |
1889
- | `suiteVerdict` | `suiteVerdict({ status, vanished = [], declared = [], fresh = [] })` | `SuiteVerdict` | deciding what a finished suite run is worth given its exit status and what it left behind; can only ever downgrade a verdict, never upgrade one |
1890
- | `runSuite` | `runSuite({ command, args = [], cwd, results = [], env = process.env, log = () => {} })` | `number` — the suite's exit status | running the repository's suite, bracketed by before/after executable checks and a fresh-results check rather than trusting the exit status alone |
1891
- | `suiteCommand` | `suiteCommand(config, mode)` | `string[]` | reading the declared suite command for a mode (`"run"` or `"open"`), or failing clearly when none is declared |
1892
- | `e2eRun` | `async e2eRun({ config, packageJson, mode = "run", suiteArgs = [], env = process.env, log = () => {} })` | `Promise<number>` | running a full, from-scratch end-to-end pass: deploy, reseed, recreate the container, wait, run the suite, tear down (or leave it serving in `"open"` mode) |
1893
- | `e2eFast` | `async e2eFast({ config, argv = [], env = process.env, log = () => {} })` | `Promise<number>` | iterating quickly during development: rebuild what changed, redeploy, cycle the world, wait, re-run |
1894
- | `e2eSweep` | `async e2eSweep({ config, packageJson, argv = [], env = process.env, log = () => {} })` | `Promise<number>` | running the full suite against a build the repository does not pin, to license moving `compatibility.verified` forward |
1888
+ | Export | Signature | Returns | Use it when |
1889
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1890
+ | `E2E_GM_ID` | `const E2E_GM_ID` | — | reading the fixed Gamemaster document id every seeded world carries |
1891
+ | `E2E_SCENE_ID` | `const E2E_SCENE_ID` | — | reading the seeded, pre-activated default scene's id |
1892
+ | `E2E_MODES` | `const E2E_MODES` | — | the closed set of modes the suite may run under (`"run"` / `"open"`) |
1893
+ | `hashPassword` | `hashPassword(password, salt)` | `string` — the hex hash | hashing a password the way Foundry's own `core/auth.mjs` does, to seed a login that actually works |
1894
+ | `resolveE2EWorld` | `resolveE2EWorld(config, env = process.env)` | `E2EWorld` | resolving the seeded world's identity from configuration, with an environment override under the repository's own variable prefix |
1895
+ | `worldManifest` | `worldManifest({ worldId, worldTitle, worldDescription, systemId, systemVersion, coreVersion })` | `object` — the world manifest | building the `world.json` a seeded world carries |
1896
+ | `gmDocument` | `gmDocument({ id, name, password, salt })` | `object` — the user document | building the seeded world's single Gamemaster user document |
1897
+ | `defaultSceneDocument` | `defaultSceneDocument()` | `object` — the scene document | building the one pre-activated scene every seeded world carries |
1898
+ | `moduleConfigurationDocument` | `moduleConfigurationDocument(packageId)` | `object` — the setting document | building the world setting that switches a module package on (a module must be activated; a system does not) |
1899
+ | `isWorldActive` | `isWorldActive(body)` | `boolean` | checking whether a `/join` response body shows a world that is actually active, not merely reachable |
1900
+ | `resolveSweepVersion` | `resolveSweepVersion(argv)` | `string` — the exact build, trimmed | reading the exact Foundry build a sweep was asked to run against; there is deliberately no default |
1901
+ | `parseFastArgs` | `parseFastArgs(argv, build)` | `FastArgs` | parsing the fast loop's CLI arguments against the repository's declared build table, in declaration order |
1902
+ | `seedTestWorld` | `async seedTestWorld({ config, packageJson, env = process.env, log = () => {}, runScript })` | `Promise<{worldDir: string, world: E2EWorld}>` | seeding the disposable world into the end-to-end stage's data root, replacing the world directory through a throwaway container so the Foundry container's user owns it |
1903
+ | `deployForContainer` | `async deployForContainer({ config, env = process.env, log = () => {}, runScript })` | `Promise<void>` | deploying the staged package to the end-to-end stage, removing the old install and handing the new one to the Foundry container's user through a throwaway container |
1904
+ | `waitForWorld` | `async waitForWorld({ url, container, stage, timeoutMs = 180_000, log = () => {} })` | `Promise<void>` | polling until a seeded world is active, or failing with a diagnosis (a licence failure is detected from the container log, not a timeout) |
1905
+ | `CHROME_BUILD_ID` | `const CHROME_BUILD_ID` | `string` | reading the exact Chrome for Testing build used by default for Cypress suites |
1906
+ | `browserCacheDir` | `browserCacheDir(env = process.env)` | `string` | locating the persistent browser cache: `HL_BROWSER_CACHE`, a sibling of `FOUNDRYVTT_CACHE`, under `HL_E2E_ROOT`, or the user's cache directory |
1907
+ | `prepareSuiteBrowser` | `async prepareSuiteBrowser({ command, args = [], env = process.env, platform = process.platform, arch = process.arch, log = () => {} })` | `Promise<string[]>` | appending the pinned Chrome executable to Cypress arguments, installing it when absent; explicit browser arguments and other suites pass through unchanged |
1908
+ | `suiteExecutables` | `suiteExecutables(command)` | `string[]` | reading every executable a suite command needs to exist before it can run at all |
1909
+ | `findExecutable` | `findExecutable(name, { cwd, env = process.env } = {})` | `string\|null` | finding an executable the way the child process would (`node_modules/.bin` first, then `PATH`) |
1910
+ | `missingExecutables` | `missingExecutables({ command, cwd, env = process.env })` | `string[]` | checking which of a suite command's executables are not there — asked again mid-run, since an install can pull the runner out from under a run in progress |
1911
+ | `freshResults` | `freshResults({ paths, since, cwd })` | `string[]` | finding which declared result paths a suite run actually wrote to since it was spawned (existence alone is not evidence) |
1912
+ | `suiteVerdict` | `suiteVerdict({ status, vanished = [], declared = [], fresh = [] })` | `SuiteVerdict` | deciding what a finished suite run is worth given its exit status and what it left behind; can only ever downgrade a verdict, never upgrade one |
1913
+ | `runSuite` | `runSuite({ command, args = [], cwd, results = [], env = process.env, log = () => {} })` | `number` — the suite's exit status | running the repository's suite, bracketed by before/after executable checks and a fresh-results check rather than trusting the exit status alone |
1914
+ | `suiteCommand` | `suiteCommand(config, mode)` | `string[]` | reading the declared suite command for a mode (`"run"` or `"open"`), or failing clearly when none is declared |
1915
+ | `e2eRun` | `async e2eRun({ config, packageJson, mode = "run", suiteArgs = [], env = process.env, log = () => {} })` | `Promise<number>` | running a full, from-scratch end-to-end pass: deploy, reseed, recreate the container, wait, run the suite, tear down (or leave it serving in `"open"` mode) |
1916
+ | `e2eFast` | `async e2eFast({ config, argv = [], env = process.env, log = () => {} })` | `Promise<number>` | iterating quickly during development: rebuild what changed, redeploy, cycle the world, wait, re-run |
1917
+ | `e2eSweep` | `async e2eSweep({ config, packageJson, argv = [], env = process.env, log = () => {} })` | `Promise<number>` | running the full suite against a build the repository does not pin, to license moving `compatibility.verified` forward |
1895
1918
 
1896
1919
  ## `./lang`
1897
1920
 
@@ -2027,6 +2050,27 @@ The engine wildcard subpath also exposes the body parsers below. Their figure ex
2027
2050
 
2028
2051
  `scanFigures` retains `line` (1-based caption line), `bodyStart`/`bodyEnd` (0-based, exclusive-end target range), and `close` (last consumed target line), plus caption attributes, kind, label and number. Unnumbered captions have an empty label and consume no number. `collectAnchors` discovers heading, caption, div, poem and span identifiers; `markupAnchorFindings(body)` reports collisions involving non-page identifiers; Foundry maps non-page anchors to the actual containing page UUID. An unnumbered `ref` requires `form="title"` or `form="full"`.
2029
2052
 
2053
+ `engine/markup-validation.mjs` provides shared syntax validation:
2054
+
2055
+ | Export | Purpose |
2056
+ | ---------------- | ----------------------------------------------------------------------------------- |
2057
+ | `markupFindings` | Validate complete Markdown source and return diagnostics for the supplied filename. |
2058
+
2059
+ `markupFindings(source, file)` masks YAML frontmatter without moving source positions and returns block and alert scanner errors as `{file, line, column, severity, message}` diagnostics. Content lint, Markdown checks and formatting preflight share this validation; malformed files are not rewritten by formatting or Markdown fixes.
2060
+
2030
2061
  `engine/content-alerts.mjs` exposes `ALERT_TYPES`, `scanAlerts(source)` and `renderAlerts(source, target)` and `alertMarkdownPlugin(md)`. The scanner returns `{blocks, errors}`; alert records carry `type`, `id`, `classes`, `attributes`, `title`, `body` and inclusive zero-based `start`/`end` source lines. The renderer produces attributed `aside.alert` wrappers while leaving alert body Markdown in the document's scope. Alert validation and anchor discovery run in site, Foundry and book pipelines.
2031
2062
 
2032
2063
  Alert `title` is the displayed heading override; absent, it defaults to the label in `ALERT_TYPES`. It does not change the alert type’s icon or color.
2064
+
2065
+ The `parsed` map accepted by content-index collection is filled by absolute
2066
+ note path with `{ frontmatter, body, raw, bodyLine?, bodyColumn? }`. Frontmatter
2067
+ is the authored value before index derivation. `buildCompileCorpus` returns
2068
+ that map as `parsed` for its document passes. `unclaimedNoteFindings` accepts it
2069
+ alongside `records`. `locateFrontmatterKey` and `assertNoDeclaredPackage` accept
2070
+ `raw` in their options to locate findings without opening the note again.
2071
+
2072
+ `prepareSqlTables` accepts sources with an optional `authoredSqlBlocks` count,
2073
+ set by `joinGeneratedSections`. Subsequent fences are generated event queries
2074
+ whose stub-inclusive results are identical; only authored fences receive a
2075
+ stub-inclusion comparison. Query answers and comparisons are cached for one
2076
+ preparation and kept separate.
@@ -31,7 +31,7 @@ The formatter keeps a collection on one line when the full line is under 100 cha
31
31
 
32
32
  ## Shared facts and game mechanics
33
33
 
34
- The [note-type reference](../reference/note-types.md) lists every field accepted under `data`, including the shared `id`, `pack`, `packFolder`, `harnworld`, and `banner` fields. A type such as `being` also has its own fields, including `homes` and `affiliations`. `culture` is shared by `being`, `place`, `affiliation`, `lore` and `doc`: it names a `lore` note of `subType: culture`, is required on a `doc` of `subType: settingguide`, and is inherited down `data.parents` by places and affiliations. See [culture in the index](../reference/format-details.md#culture-in-the-index). A field absent from a type's vocabulary is a lint error; an unrelated field is not silently repurposed.
34
+ The [note-type reference](../reference/note-types.md) lists every field accepted under `data`, including the shared `id`, `pack`, `packFolder`, `harnworld`, `banner`, and `seeAlso` fields. A type such as `being` also has its own fields, including `homes` and `affiliations`. `culture` is shared by `being`, `place`, `affiliation`, `lore` and `doc`: it names a `lore` note of `subType: culture`, is required on a `doc` of `subType: settingguide`, and is inherited down `data.parents` by places and affiliations. See [culture in the index](../reference/format-details.md#culture-in-the-index). A field absent from a type's vocabulary is a lint error; an unrelated field is not silently repurposed.
35
35
 
36
36
  A being's `data.archetypes` names the roles it can fill in an adventure. Choose every specific role that fits, such as `entertainer` for a performer or `guildsperson` for someone whose professional training or connections matter. Use `[commoner]` when no specific archetype fits. `commoner` cannot share the list with another value. The [archetype reference](../reference/format-details.md#actors) defines the available roles.
37
37
 
@@ -41,6 +41,35 @@ A `sohl:`, `hm3:` or `dnd5e:` block holds values for that Foundry system. Its `s
41
41
 
42
42
  One body describes the subject for both systems. A being's `{#appearance}` and `{#dossier}` sections feed SoHL Actor `system.appearance` and `system.dossier`, and HM3 Actor `system.description` and `system.biography`. An Item's `{#description}` page becomes a shared JournalEntryPage. Its resolved `@UUID[...]` pointer is stored in SoHL Item `system.docHtml` and HM3 Item `system.description`. An Item with no `{#description}` anchor uses its first page. Other H1 pages remain linkable.
43
43
 
44
+ ## Suggested reading
45
+
46
+ Use `data.seeAlso` for deliberate cross-references. Each list entry has a required
47
+ `address` naming another note and an optional `gloss`: a short phrase explaining
48
+ why to follow it. The list keeps the order you write.
49
+
50
+ ```yaml
51
+ data:
52
+ seeAlso:
53
+ - address: lore-harborhistory
54
+ gloss: The founding of the port
55
+ - address: place-breakwater
56
+ - address: thalorna-note-lore-searoutes
57
+ gloss: Routes beyond the bay
58
+ ```
59
+
60
+ Here the harbor history comes first, the breakwater needs no explanation, and
61
+ the sea routes belong to the declared `thalorna` dependency. Write bare Addresses,
62
+ without wikilink brackets or anchors; include the note type, since this field
63
+ has no default type. Local references default to this package and the `note`
64
+ system. A reference must resolve in this package or a declared dependency.
65
+ An unresolved address, duplicate destination, or reference to the note itself
66
+ is an error at that entry's address.
67
+
68
+ The website places these links and glosses in a **See also** group ahead of
69
+ inferred connections in **Related**. A destination also found among backlinks
70
+ or mentions appears only in See also. Foundry journals and books append a
71
+ **See also** section. An omitted or empty list adds no section or group.
72
+
44
73
  ## Place governments
45
74
 
46
75
  A place may state its governing affiliation in `data.government`. The field type is `Address`, with `affiliation` as the default target type when the type is omitted. Use either a local shortcode or a fully qualified affiliation Address from this package or a fetched dependency:
@@ -178,6 +178,20 @@ The harbor master is working for the smugglers.
178
178
  :::
179
179
  ```
180
180
 
181
+ Leave a blank line between a blockquote or alert and the closing `:::`. Without it, Markdown treats the fence as part of the quote and leaves the enclosing block unclosed:
182
+
183
+ ```markdown
184
+ :::secret {title="For the GM"}
185
+ Hidden information.
186
+
187
+ > [!TIP]
188
+ > Advice for the GM.
189
+
190
+ :::
191
+ ```
192
+
193
+ `lint`, `markdown` and `format` refuse malformed block and alert syntax, including unclosed divs and unknown alert types. `format` leaves a malformed file unchanged so you can correct the reported opening line before formatting it.
194
+
181
195
  A secret block can contain alerts and general divs. It cannot contain another secret block. A heading that starts its own journal page cannot split a secret block or general div; use a lower heading without an anchor within the section.
182
196
 
183
197
  Foundry renders a secret section; the website renders an expandable spoiler, and the book renders a labeled box. An untitled secret takes its normal default title, and a Foundry secret without an id receives an id derived from its body. The presentation does not make published HTML private: deliberately inspecting generated source can reveal its content. Use YAML comments for information that must never be published.
package/docs/commands.md CHANGED
@@ -1995,8 +1995,8 @@ package-build lint [root] [--no-references]
1995
1995
  **DESCRIPTION**
1996
1996
 
1997
1997
  Checks a content tree's addresses and frontmatter without compiling anything
1998
- — no LevelDB is opened and no Foundry manifest is needed, so it runs in
1999
- under a second and can gate a commit. Beyond address well-formedness, it
1998
+ — no LevelDB is opened and no Foundry manifest is needed. Beyond address
1999
+ well-formedness, it
2000
2000
  checks frontmatter against the declared vocabulary and each system's `sohl:`
2001
2001
  / `hm3:` block, the package's charset (so a book can pick a font that covers
2002
2002
  it), that an icon a note writes is one the registry declares, and that
@@ -2012,6 +2012,16 @@ dependencies; exact Item UUID validation remains part of compilation. Reads
2012
2012
  the content tree named by `root`, defaulting to
2013
2013
  `paths.content`; writes nothing.
2014
2014
 
2015
+ Content lint computes source positions when it emits findings and shares each
2016
+ note's YAML position document across consecutive lookups.
2017
+
2018
+ The final summary reports wall time in milliseconds for `index` (collecting
2019
+ content records), `SQL` (opening relations and preparing tables, inline queries
2020
+ and generated sections), and `validation` (building the link index and running
2021
+ the checks). These are elapsed durations from a monotonic clock, including any
2022
+ waits within each stage. They are prose summary output, separate from positioned
2023
+ findings, and appear whether completed validation passes or reports errors.
2024
+
2015
2025
  **OPTIONS**
2016
2026
 
2017
2027
  | Positional | Type | Default | Description |
@@ -91,6 +91,29 @@ errors, reported at the `ranks` key.
91
91
 
92
92
  An Item's `{#description}` page is its readable description. The note Address `<package>-note-<type>-<shortcode>#description` resolves to a JournalEntryPage UUID. The build writes the same `@UUID[...]` pointer to SoHL Item `system.docHtml` and HM3 Item `system.description`. An Item note without that anchor uses its first page as the description. Other pages and anchors remain available for links.
93
93
 
94
+ ## Suggesting another note
95
+
96
+ State deliberate cross-references in `data.seeAlso`, in reading order:
97
+
98
+ ```yaml
99
+ data:
100
+ seeAlso:
101
+ - address: lore-harborhistory
102
+ gloss: The founding of the port
103
+ - address: place-breakwater
104
+ - address: thalorna-note-lore-searoutes
105
+ gloss: Routes beyond the bay
106
+ ```
107
+
108
+ Each `address` names a whole note; `gloss` optionally explains why to read it.
109
+ The first entry points to the port's history, the second to its breakwater,
110
+ and the third to a note in the declared `thalorna` dependency. An address must
111
+ resolve, and neither a duplicate destination nor the note itself is allowed.
112
+ The site shows the list ahead of other Related links, without repeating their
113
+ destinations. Journals and books append See also; an omitted or empty list adds
114
+ nothing. See [suggested reading](authoring/frontmatter.md#suggested-reading)
115
+ for the Address rules.
116
+
94
117
  ## File and folder names
95
118
 
96
119
  Every character in the path of a content file or a picture is printable 7-bit ASCII, U+0020 to U+007E. The rule covers each file and folder name under the content tree and under the package's `icons/` and `images/` roots. Letters, digits, spaces, hyphens, underscores, dots and apostrophes are all inside it, so `Knight's_Shield.md` is a valid name. An accented letter, any other non-ASCII character, and a control character are outside it.
package/docs/design.md CHANGED
@@ -30,3 +30,34 @@ Passes declare their inputs and outputs so compile order follows dependencies wh
30
30
  ## Verification
31
31
 
32
32
  The command and API references have tests that derive their surfaces from source. The note-format contract lives in structured data and code declarations so editorial changes to Markdown do not change validation. Generated field references are checked against those declarations. Tests check emitted documents as well as input acceptance: a build that exits successfully but drops a value is still wrong. A consumer regression run compares `build/packs-json` across toolchain versions against a real content tree.
33
+
34
+ The SQL scaling guard runs `prepareTreeSqlTables` over two sizes of a synthetic
35
+ linked note tree, with rewritten shortcodes and references. It profiles each
36
+ executed query through the supplied database, including local and dependency
37
+ relations, inline expressions and generated event sections. DuckDB's JSON
38
+ `EXPLAIN ANALYZE` reports the actual cardinality of file-reader operators before
39
+ downstream filters; these source rows must stay at zero after database creation.
40
+ A direct JSON scan is the positive control for that measurement. In-memory
41
+ scans may grow with the corpus, and elapsed time is not an assertion. Schema
42
+ inspection through `DESCRIBE` has no execution profile and is excluded.
43
+
44
+ Run the guard with `npm test -- tests/sql-scaling.test.ts`. A controlled local
45
+ change from `CREATE TABLE` to `CREATE VIEW` in `createTableFromFile` must make
46
+ it fail as source rows read per query increase with the larger tree; restore
47
+ the implementation after the probe. The companion relation tests check that
48
+ queries survive source-file removal and enumerate file-reading views.
49
+
50
+ A compile retains parsed frontmatter, body, raw source and body coordinates in
51
+ its corpus. SQL preparation, document passes, folder pins, event anchors,
52
+ culture inheritance and prose-presence checks consume that shared result.
53
+ The compile guard counts actual Markdown reads and YAML parses for every file
54
+ in its fixture, including documentation and folder notes, and requires one of
55
+ each. Diagnostic positions consume retained source text when supplied.
56
+
57
+ Standing checks locate only findings they emit. YAML path lookups retain the
58
+ most recently located text's document and line counter; another text replaces
59
+ that single cache entry, so an edited note cannot inherit stale coordinates.
60
+ SQL preparation caches authored answers and stub-inclusive comparisons in
61
+ separate fields per query. Generated event fences read `entries` and `events`
62
+ and skip the comparison; their authored/generated boundary is recorded when
63
+ the sections are appended, independently of query text.
@@ -128,6 +128,33 @@ for interactive testing. Arguments after `--` are passed to the suite, for
128
128
  example `package-build e2e run -- --browser chrome`. A port answering is not
129
129
  enough: the harness waits until Foundry's join page shows the world is active.
130
130
 
131
+ ## Choose a browser
132
+
133
+ For a Cypress suite, `e2e fast`, `e2e run` and `e2e open` use pinned
134
+ Chrome for Testing **155.0.8059.84** on macOS arm64 and Linux x64. The first run
135
+ downloads it; later runs reuse the cached executable. Each run prints the
136
+ resolved browser and version, and Cypress reports the browser in its header.
137
+ Other suite commands run as declared.
138
+
139
+ An explicit `--browser` in the configured suite or its extra arguments wins,
140
+ including `--browser=electron`. For example:
141
+
142
+ ```bash
143
+ package-build e2e fast -- --browser electron
144
+ package-build e2e run -- --browser /path/to/chrome
145
+ package-build e2e open -- --browser=chrome
146
+ ```
147
+
148
+ A platform without a configured build requires an explicit installed browser.
149
+ The automatic download needs network access on first use. The cache location is,
150
+ in order: `HL_BROWSER_CACHE`, `browser-cache` beside `FOUNDRYVTT_CACHE`,
151
+ `HL_E2E_ROOT/browser-cache`, or `heroiclands/chrome-for-testing` under the user's
152
+ cache directory (`XDG_CACHE_HOME`, macOS `~/Library/Caches`, otherwise
153
+ `~/.cache`). CI keeps it inside the job's persistent `HL_E2E_ROOT`; point
154
+ `FOUNDRYVTT_CACHE` there too, or set `HL_BROWSER_CACHE` explicitly. Reseeding
155
+ and container teardown leave the browser cache intact. Renovate tracks the
156
+ exact Chrome pin in `e2e.mjs` through the repository's custom datasource.
157
+
131
158
  ## Iterate without reseeding
132
159
 
133
160
  `fast` rebuilds declared targets, redeploys the full stage, cycles the world,
@@ -196,4 +223,13 @@ container started with the variable set joins the network but keeps the
196
223
  environment it was created with; `--recreate` applies `FOUNDRY_AWS_CONFIG`.
197
224
  `package-build container <stage> rm` with the variable set removes the server
198
225
  and the network along with the container. The server is otherwise left running
199
- between runs, bucket intact.
226
+ between runs, bucket intact. New and reused servers must answer the S3 HTTP
227
+ health check before Foundry starts; a ready filer alone is insufficient.
228
+
229
+ Both configuration paths must be readable regular files. When changing
230
+ worktrees, an existing server must bind the identities file from the current
231
+ checkout (symlinks to the same file are accepted). An incompatible or
232
+ uninspectable bind stops the start and names the server and expected path.
233
+ Restore the original checkout to keep its bucket data, or remove only the named
234
+ disposable S3 server with `docker rm -f <container>-s3` and rerun. Removing that
235
+ server discards its bucket data; the tool never removes it automatically.
@@ -78,24 +78,39 @@ who knows the groups can guess a name.
78
78
  ### `prepare` — the git hooks
79
79
 
80
80
  ```json
81
- "prepare": "git config core.hooksPath node_modules/@heroiclands/package-build/githooks || true"
81
+ "prepare": "git config core.hooksPath .githooks || true"
82
82
  ```
83
83
 
84
- npm runs `prepare` after every install, so this is what installs the hooks. It
85
- points git at the hook directory this package ships, which means a repository
86
- carries no hook files of its own and cannot drift from the others. `|| true` so
87
- that an install outside a git checkout — a Docker build, a CI cache step —
88
- does not fail on it.
84
+ npm runs `prepare` after every install. In this repository it points Git at the
85
+ committed `.githooks/`, including in a fresh worktree. Run `npm ci` for a fresh
86
+ checkout or `npm run prepare` to repeat setup. `|| true` allows an install
87
+ outside a Git checkout, such as a Docker build or CI cache step.
89
88
 
90
- Four hooks arrive with it, each with its own switch, read with git's normal
89
+ The installed `commit-msg` and `pre-commit` are byte-identical copies of the
90
+ [canonical POSIX hooks](https://github.com/HeroicLands/.github#local-git-hooks-and-actionsgithooks).
91
+ The message guard refuses assistant credits without requiring Node; the branch
92
+ guard refuses commits on `main` and `master`. These hooks honor
93
+ `hooks.noAttribution`, `hooks.protectedBranch`, and `hooks.allowCommitOnMain`.
94
+ CI compares every installed hook against the pinned canonical set. This setup
95
+ installs no pre-push or pre-merge-commit hook.
96
+
97
+ ### Optional published hook bundle
98
+
99
+ The package also ships `githooks/` for repositories that explicitly select its
100
+ additional checks. To select that bundle, point `core.hooksPath` at
101
+ `node_modules/@heroiclands/package-build/githooks` in that repository’s setup.
102
+ That bundle requires the installed package and Node; it is separate from this
103
+ repository’s canonical `.githooks/` installation.
104
+
105
+ Four hooks are available, each with its own switch, read with Git’s normal
91
106
  precedence: a plain `git config` sets one clone, `--global` sets a machine.
92
107
 
93
- | Hook | What it does | Key | Default |
94
- | -------------------------------- | --------------------------------------------------------------- | ------------------------------------ | ------- |
95
- | `pre-commit`, `pre-merge-commit` | Refuse a commit on a protected branch. | `hooks.allowCommitOnMain` (inverted) | on |
96
- | `pre-commit` | Lint any `.changeset/*.md` the commit stages. | `hooks.changelogCheck` | on |
97
- | `commit-msg` | Refuse AI attribution in a commit message. | `hooks.noAttribution` | on |
98
- | `pre-push` | Run this repository's own Build & Test workflow in a container. | `hooks.prePushCi` | off |
108
+ | Hook | What it does | Key | Default |
109
+ | -------------------------------- | -------------------------------------------------------------------- | ------------------------------------ | ------- |
110
+ | `pre-commit`, `pre-merge-commit` | Refuse a commit on a protected branch. | `hooks.allowCommitOnMain` (inverted) | on |
111
+ | `pre-commit` | Lint any `.changeset/*.md` the commit stages. | `hooks.changelogCheck` | on |
112
+ | `commit-msg` | Refuse AI attribution in a commit message. | `hooks.noAttribution` | on |
113
+ | `pre-push` | Run the consuming repository’s Build & Test workflow in a container. | `hooks.prePushCi` | off |
99
114
 
100
115
  The defaults differ deliberately: a guard that costs nothing is on unless
101
116
  refused, and one that runs a container for minutes is off unless asked for.
@@ -120,8 +135,8 @@ Git comment lines and the verbose diff after a scissors comment are excluded.
120
135
  Findings report the original message line and column. This local hook examines
121
136
  commit messages only; the shared No Attribution workflow also checks pull
122
137
  request titles and bodies. Neither examines repository files, issues, comments,
123
- or Git author/committer metadata. The package-build repository's own hook uses
124
- the same matching code and remains enabled independently of the consumer switch.
138
+ or Git author/committer metadata. The canonical shell hooks apply the same
139
+ credit grammar with their own POSIX implementation.
125
140
 
126
141
  The branch guard is the one you meet first:
127
142