@heroiclands/package-build 8.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,124 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 8.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 333c340: Follow a spread of an imported schema function, and refuse a computed field name.
8
+
9
+ Two defects in `package-build schema`, both found by running it against real
10
+ content rather than by reading it.
11
+
12
+ **A shared base schema spread from another file was dropped, in silence.** A
13
+ concrete DataModel spreads a shared builder by name — `...defineSohlDataSchema()`
14
+ — and the resolver looked for that function only in the file doing the
15
+ spreading. When it was imported, the lookup found nothing and the spread
16
+ contributed nothing, with no error: a spread of a missing function read exactly
17
+ like a spread of an empty one.
18
+
19
+ The effect was not small. Every SoHL Item and Actor subtype lost `shortcode`,
20
+ `actionDefs`, `lastRun` and `scheduledActions` from the published schema. So
21
+ content correctly authoring `system.shortcode` — which SoHL requires to be
22
+ unique per `(type, shortcode)` on an actor, and which content therefore sets
23
+ deliberately — was reported as emitting a field no DataModel declares. The check
24
+ was accusing the content of the reader's own blind spot, which is worse than not
25
+ checking: it is a false accusation delivered with the same confidence as a true
26
+ one.
27
+
28
+ An imported spread is now resolved through the import, with a same-file
29
+ definition still taking precedence.
30
+
31
+ **A computed field name is now refused rather than published as source text.**
32
+ `[`${name}Date`]: worldTimeDateField()` takes its real name from an argument
33
+ this reader does not evaluate, and the previous behaviour handed back the source
34
+ text — putting a field called ``[`${name}Date`]`` into the schema. That field
35
+ matches nothing any builder could emit: absent for checking purposes while
36
+ looking present, and permanently reported as unemitted.
37
+
38
+ It now stops, naming the file and the key:
39
+
40
+ ```text
41
+ temporal-fields.ts declares a schema field with a computed name,
42
+ `[`${name}DurationFormula`]`, whose value depends on an argument this reader
43
+ does not evaluate. Write the keys out so the published schema can name them.
44
+ ```
45
+
46
+ Stopping is the same stance `compareFields` already takes on an artifact of the
47
+ wrong version: the schema is a contract other repositories read, so a contract
48
+ this cannot state is worth failing for rather than approximating. The fix
49
+ belongs at the source, where the names are actually decided.
50
+
51
+ **Bump**
52
+
53
+ _Minor._ Repositories whose schemas spread an imported builder will publish more
54
+ fields than before — which is the correction. A repository using computed field
55
+ names now fails where it previously produced a wrong artifact; none does today
56
+ except the one this was found on, and that is being fixed at the source.
57
+
58
+ ### Patch Changes
59
+
60
+ - 2319a56: Bump `markdown-it` from 15.0.0 to 15.0.1 (#121).
61
+
62
+ The surface is unchanged from the 15.0.0 adoption: one constructor,
63
+ `markdownit({ html: true })` in `engine/helpers.mjs`, and three `md.render()`
64
+ call sites — `engine/helpers.mjs`, `engine/journals.mjs` and `sohl/actors.mjs`.
65
+ No plugin is installed, no renderer rule is overridden, and `linkify` is left at
66
+ its default `false`. That last fact decides most of this release.
67
+
68
+ **Two of the five changes cannot reach a build that never linkifies.**
69
+
70
+ | Release note | Reaches here? |
71
+ | --------------------------------------------------------- | ---------------------------------------------------------------- |
72
+ | _Security:_ quadratic complexity replacing fuzzy links | No — `linkify` is `false`, so the fuzzy-link replacer never runs |
73
+ | _Security:_ quadratic complexity in scheme backscan | No — same; the rule is the inline **linkify** rule |
74
+ | Preserve brackets around IPv6 literals in `normalizeLink` | Yes, in principle — no content in any consumer writes one |
75
+ | Preserve spaces in all-space code spans (#1180) | Yes, for runs of three or more spaces only |
76
+ | Code spans after unclosed link/image labels (#1201) | Yes, for an unclosed `[` followed by an odd-length backtick run |
77
+
78
+ Calling the first two "security" fixes is upstream's framing and the honest
79
+ reading of it here is that this package was never exposed: both are complexity
80
+ bounds on `linkify-it` code paths that are unreachable with the linkifier off.
81
+ Taking the bump is hygiene, not remediation.
82
+
83
+ **The three real fixes were measured, not assumed.** Both versions were rendered
84
+ through `markdownit({ html: true })` — this package's exact configuration — and
85
+ compared:
86
+
87
+ ```text
88
+ a ` ` b 15.0.0 → <p>a <code> </code> b</p> 15.0.1 → <p>a <code> </code> b</p>
89
+ [`a`b` 15.0.0 → <p>[`a`b`</p> 15.0.1 → <p>[<code>a</code>b`</p>
90
+ [x](http://[::1]/)
91
+ 15.0.0 → href="http://%5B::1%5D/" 15.0.1 → href="http://[::1]/"
92
+ ```
93
+
94
+ Each has a narrow trigger. The code-span fix moves output only at three spaces
95
+ or more — 15.0.0 stripped one space from each end of an all-space span, so one-
96
+ and two-space spans were already correct and stay byte-identical. The unclosed-
97
+ label fix needs the backtick run to be **odd**: `[Sword `hp`and`ac`` renders
98
+ the same on both, because its four backticks close. And the IPv6 fix only fires
99
+ on a bracketed address literal in a link, image or autolink destination, which
100
+ 15.0.0 percent-encoded into a host no browser resolves.
101
+
102
+ **Verified against every consumer, and against a search rather than a guess.**
103
+ All 14,995 `.md` files across the six HeroicLands content repositories — `sohl`,
104
+ `hm3`, `thalorna`, `kethira`, `harnensemble` and `harnadventures` — were parsed
105
+ the way `parseMarkdownFile` parses them and rendered through both versions.
106
+ Every one is byte-identical. Scanning the same corpus explains why rather than
107
+ leaving it to luck: zero files contain an IPv6 URL, zero contain an all-space
108
+ code span of three or more spaces, and the 109 files that do use `` ` ` `` use
109
+ the one-space form the fix does not touch. Beyond the corpus, an exhaustive
110
+ render of all 488,280 strings up to eight characters over `[`, `]`, `` ` ``,
111
+ `x` and `!` — the alphabet the label-and-backtick fix is about — found the
112
+ divergence set to be exactly the shapes above and nothing else. The repository's
113
+ 1,713 tests pass unchanged.
114
+
115
+ **Bump**
116
+
117
+ _Patch, not minor._ No export, option, or emitted document changes shape. The
118
+ only behaviour that moves is markdown that was rendered wrongly before, on
119
+ constructs no consumer writes, and the two changes advertised as security fixes
120
+ are unreachable from this configuration.
121
+
3
122
  ## 8.0.0
4
123
 
5
124
  ### Major Changes
@@ -95,6 +95,37 @@ function propName(name) {
95
95
  return name.getText();
96
96
  }
97
97
 
98
+ /**
99
+ * A *field* name, which unlike a property name elsewhere must be knowable.
100
+ *
101
+ * A computed key — `[`${name}Date`]: worldTimeDateField()` — depends on an
102
+ * argument this reader does not evaluate, so its real name is not in the file.
103
+ * {@link propName} would hand back the source text, and a field called
104
+ * ``[`${name}Date`]`` matches nothing a builder could ever emit: it is absent
105
+ * from the schema for checking purposes while *looking* present, and it shows
106
+ * up as permanently unemitted noise.
107
+ *
108
+ * So this refuses rather than guessing. The schema is a contract other
109
+ * repositories read, and a contract it cannot state is worth stopping for —
110
+ * the same reason `compareFields` refuses an artifact of the wrong version
111
+ * instead of resolving it anyway. Writing the keys out fixes it at the source,
112
+ * where the names are actually decided.
113
+ *
114
+ * @param {ts.PropertyName} name - The property name node.
115
+ * @param {string} file - For the message.
116
+ * @returns {string} The literal field name.
117
+ * @throws {Error} When the name is computed.
118
+ */
119
+ function fieldName(name, file) {
120
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
121
+ throw new Error(
122
+ `${path.basename(file)} declares a schema field with a computed name, ` +
123
+ `\`${name.getText()}\`, whose value depends on an argument this ` +
124
+ `reader does not evaluate. Write the keys out so the published ` +
125
+ `schema can name them.`,
126
+ );
127
+ }
128
+
98
129
  /**
99
130
  * The `subtype: ClassName` entries of a registry object literal.
100
131
  *
@@ -424,16 +455,16 @@ function isSchemaField(expr) {
424
455
  }
425
456
 
426
457
  /** The keys of a `new SchemaField({ … })`, recursively dotted. */
427
- function nestedKeysOf(expr) {
458
+ function nestedKeysOf(expr, file) {
428
459
  if (!isSchemaField(expr)) return [];
429
460
  const arg = expr.arguments?.[0];
430
461
  if (!arg || !ts.isObjectLiteralExpression(arg)) return [];
431
462
  const out = [];
432
463
  for (const p of arg.properties) {
433
464
  if (!ts.isPropertyAssignment(p)) continue;
434
- const key = propName(p.name);
465
+ const key = fieldName(p.name, file);
435
466
  out.push(key);
436
- for (const child of nestedKeysOf(p.initializer)) {
467
+ for (const child of nestedKeysOf(p.initializer, file)) {
437
468
  out.push(`${key}.${child}`);
438
469
  }
439
470
  }
@@ -445,7 +476,7 @@ function nestedKeysOf(expr) {
445
476
  *
446
477
  * @returns {{own: string[], edges: object[]}}
447
478
  */
448
- function readLiteral(literal) {
479
+ function readLiteral(literal, file) {
449
480
  const own = [];
450
481
  const edges = [];
451
482
  for (const p of literal.properties) {
@@ -455,9 +486,9 @@ function readLiteral(literal) {
455
486
  continue;
456
487
  }
457
488
  if (!ts.isPropertyAssignment(p)) continue;
458
- const key = propName(p.name);
489
+ const key = fieldName(p.name, file);
459
490
  own.push(key);
460
- for (const child of nestedKeysOf(p.initializer)) {
491
+ for (const child of nestedKeysOf(p.initializer, file)) {
461
492
  own.push(`${key}.${child}`);
462
493
  }
463
494
  }
@@ -505,7 +536,7 @@ export function fieldsForClass({ file, className, aliases, cache, rootDir }) {
505
536
 
506
537
  /** Walk a schema literal, following the spreads inside it. */
507
538
  const walkLiteral = (located, literal, into, superName) => {
508
- const { own: fields, edges } = readLiteral(literal);
539
+ const { own: fields, edges } = readLiteral(literal, located.file);
509
540
  into.push(...fields);
510
541
  for (const edge of edges) followEdge(located, edge, superName);
511
542
  };
@@ -516,12 +547,34 @@ export function fieldsForClass({ file, className, aliases, cache, rootDir }) {
516
547
  const key = `local:${located.file}:${edge.name}`;
517
548
  if (seen.has(key)) return;
518
549
  seen.add(key);
519
- // A local spread inside a subtype's own definition is still the
520
- // parent's contribution — a shared `defineXDataSchema()` is where
521
- // the common fields come from — so it lands in `inherited`
522
- // whichever file it is written in.
550
+ // A spread of a schema-building function is the parent's
551
+ // contribution — a shared `defineXDataSchema()` is where the common
552
+ // fields come from — so it lands in `inherited` whichever file it
553
+ // is written in.
523
554
  const lit = literalReturnedBy(located.src, edge.name);
524
- if (lit) walkLiteral(located, lit, inherited, superName);
555
+ if (lit) {
556
+ walkLiteral(located, lit, inherited, superName);
557
+ return;
558
+ }
559
+ // Not declared here, so it was imported. Following it matters more
560
+ // than it looks: the shared base schema is spread by name from the
561
+ // file that exports it, and resolving only same-file functions
562
+ // dropped it **entirely and in silence** — every SoHL subtype lost
563
+ // `shortcode` and `actionDefs`, so content correctly authoring
564
+ // `system.shortcode` was reported as undeclared. A spread that
565
+ // resolves to nothing must not read as a spread of nothing.
566
+ const from = importSourceOf(located.src, edge.name, aliases);
567
+ if (!from) return;
568
+ const importedSrc = parse(from, cache);
569
+ const importedLit = literalReturnedBy(importedSrc, edge.name);
570
+ if (importedLit) {
571
+ walkLiteral(
572
+ { file: from, src: importedSrc },
573
+ importedLit,
574
+ inherited,
575
+ superName,
576
+ );
577
+ }
525
578
  return;
526
579
  }
527
580
  const name = edge.kind === "super" ? superName : edge.name;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "8.0.0",
3
+ "version": "8.1.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",