@sharpee/chord 3.0.0 → 3.3.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 (55) hide show
  1. package/analyzer.d.ts +11 -3
  2. package/analyzer.d.ts.map +1 -1
  3. package/analyzer.js +1217 -33
  4. package/analyzer.js.map +1 -1
  5. package/ast.d.ts +458 -9
  6. package/ast.d.ts.map +1 -1
  7. package/catalog.d.ts +57 -1
  8. package/catalog.d.ts.map +1 -1
  9. package/catalog.js +95 -3
  10. package/catalog.js.map +1 -1
  11. package/diagnostics.d.ts +1 -1
  12. package/diagnostics.d.ts.map +1 -1
  13. package/index.d.ts +31 -13
  14. package/index.d.ts.map +1 -1
  15. package/index.js +110 -30
  16. package/index.js.map +1 -1
  17. package/ir.d.ts +316 -7
  18. package/ir.d.ts.map +1 -1
  19. package/lexer.d.ts +11 -3
  20. package/lexer.d.ts.map +1 -1
  21. package/lexer.js +36 -8
  22. package/lexer.js.map +1 -1
  23. package/manifests/combat.d.ts +16 -0
  24. package/manifests/combat.d.ts.map +1 -0
  25. package/manifests/combat.js +38 -0
  26. package/manifests/combat.js.map +1 -0
  27. package/manifests/index.d.ts +21 -0
  28. package/manifests/index.d.ts.map +1 -0
  29. package/manifests/index.js +46 -0
  30. package/manifests/index.js.map +1 -0
  31. package/manifests/npc.d.ts +19 -0
  32. package/manifests/npc.d.ts.map +1 -0
  33. package/manifests/npc.js +38 -0
  34. package/manifests/npc.js.map +1 -0
  35. package/manifests/state-machines.d.ts +17 -0
  36. package/manifests/state-machines.d.ts.map +1 -0
  37. package/manifests/state-machines.js +8 -0
  38. package/manifests/state-machines.js.map +1 -0
  39. package/manifests/types.d.ts +41 -0
  40. package/manifests/types.d.ts.map +1 -0
  41. package/manifests/types.js +18 -0
  42. package/manifests/types.js.map +1 -0
  43. package/message-alias-catalog.d.ts +22 -0
  44. package/message-alias-catalog.d.ts.map +1 -0
  45. package/message-alias-catalog.js +830 -0
  46. package/message-alias-catalog.js.map +1 -0
  47. package/package.json +1 -1
  48. package/parser.d.ts +2 -2
  49. package/parser.d.ts.map +1 -1
  50. package/parser.js +1566 -148
  51. package/parser.js.map +1 -1
  52. package/phrasebooks.d.ts +28 -0
  53. package/phrasebooks.d.ts.map +1 -0
  54. package/phrasebooks.js +6 -0
  55. package/phrasebooks.js.map +1 -0
package/analyzer.js CHANGED
@@ -1,10 +1,24 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeTopic = normalizeTopic;
3
4
  exports.analyze = analyze;
4
- const catalog_1 = require("./catalog");
5
- const ir_1 = require("./ir");
5
+ const catalog_js_1 = require("./catalog.js");
6
+ const index_js_1 = require("./manifests/index.js");
7
+ const phrasebooks_js_1 = require("./phrasebooks.js");
8
+ const ir_js_1 = require("./ir.js");
6
9
  /** Phase A stories register text in this locale (design.md §2.6). */
7
10
  const DEFAULT_LOCALE = 'en-US';
11
+ /**
12
+ * Exit-direction opposites (parser DIRECTIONS vocabulary) — the same
13
+ * inference every plain exit line performs platform-side; here it backs
14
+ * the door mirror-line check (ADR-234 D2/D3, `checkDoors`).
15
+ */
16
+ const OPPOSITE_DIRECTION = {
17
+ north: 'south', south: 'north', east: 'west', west: 'east',
18
+ northeast: 'southwest', southwest: 'northeast',
19
+ northwest: 'southeast', southeast: 'northwest',
20
+ up: 'down', down: 'up',
21
+ };
8
22
  const PLAYER_WORDS = new Set(['player', 'you', 'yourself']);
9
23
  /** Reserved-name gate text (David, 2026-07-12 — each package P3). */
10
24
  const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder `the match` (ratchet E3) — pick another name.';
@@ -15,8 +29,27 @@ const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder
15
29
  * block is the one authoring surface.
16
30
  */
17
31
  const RESERVED_CHANNEL_KEYS = new Set(['present', 'entered', 'exited', 'disappeared', 'detail']);
32
+ /**
33
+ * Image layers the platform pre-registers (`image:background|main|
34
+ * overlay`, stdlib MEDIA_CHANNELS) — implied like the `main` ambient
35
+ * bed; `define layer` covers only layers beyond these (ADR-241 D3).
36
+ */
37
+ const IMPLIED_IMAGE_LAYERS = new Set(['background', 'main', 'overlay']);
18
38
  /** Ring 1 of the boolean-state gate (D9): literal booleans as state names. */
19
39
  const BOOLEAN_STATE_WORDS = new Set(['true', 'false', 'yes', 'no']);
40
+ /**
41
+ * ADR-239 D4 topic normalization — ONE implementation for both halves of
42
+ * the lookup contract: the analyzer's overlap gates here, and the
43
+ * story-loader's runtime table lookup (which imports this). Rules:
44
+ * case-insensitive, leading article stripped, whitespace collapsed.
45
+ * Whole-topic equality only; never substring matching.
46
+ */
47
+ function normalizeTopic(text) {
48
+ const words = text.trim().toLowerCase().split(/\s+/);
49
+ if (words.length > 1 && (words[0] === 'the' || words[0] === 'a' || words[0] === 'an'))
50
+ words.shift();
51
+ return words.join(' ');
52
+ }
20
53
  /**
21
54
  * Negation prefixes/suffix for ring 3 of the boolean-state gate (D9):
22
55
  * `not-`/`un-`/`non-` (hyphenated or fused), `no-` (hyphenated only — bare
@@ -61,6 +94,8 @@ function conditionFingerprint(cond) {
61
94
  return `not(${conditionFingerprint(cond.operand)})`;
62
95
  case 'chance':
63
96
  return `chance:${cond.n}`;
97
+ case 'client-has':
98
+ return `client-has:${cond.capability}`;
64
99
  case 'condition-ref':
65
100
  return `cond:${cond.name}`;
66
101
  case 'any-of':
@@ -97,6 +132,8 @@ const STANDARD_ACTION_ROLES = {
97
132
  taking: ['taker', 'item'],
98
133
  };
99
134
  const TOP_SCOPE = { owner: null, fields: null, slots: null, ownStates: null, scoreOwner: null, inEach: false };
135
+ /** Scope of a story-owned clause (ADR-236 D7): no owner, `it` unbound. */
136
+ const STORY_SCOPE = { ...TOP_SCOPE, storyOwned: true };
100
137
  function entityScope(owner) {
101
138
  return { owner, fields: null, slots: null, ownStates: null, scoreOwner: owner?.id ?? null, inEach: false };
102
139
  }
@@ -139,6 +176,7 @@ function conditionReferencesIt(cond) {
139
176
  return conditionReferencesIt(cond.operand);
140
177
  case 'chance':
141
178
  case 'condition-ref':
179
+ case 'client-has':
142
180
  return false;
143
181
  case 'any-of':
144
182
  case 'none-of':
@@ -187,10 +225,20 @@ class Analyzer {
187
225
  hatchNames = new Set();
188
226
  /** locale → key → IRPhrase */
189
227
  phrases = new Map();
228
+ /** ADR-255: locale → override alias → IRPhrase (validated against MESSAGE_OVERRIDE_ALIASES). */
229
+ messageOverrides = new Map();
190
230
  // Phase B namespaces:
191
231
  traitNames = new Set();
192
232
  /** action name → declared grammar-slot names. */
193
233
  actionSlots = new Map();
234
+ /**
235
+ * emit event id (dotless) → the top-level payload field names it carries
236
+ * (ADR-253 D1). Collected across every `emit` during resolution so
237
+ * `checkChannelReturns` can flag a channel returning a field the event never
238
+ * emits. An event with no collected entry (a platform event, or one never
239
+ * emitted here) is left unchecked — the field set is unknown, not empty.
240
+ */
241
+ emitFields = new Map();
194
242
  /** Owner-qualified score id (`pygmy-goats.fed`, story-level bare) → worth. */
195
243
  scoreNames = new Map();
196
244
  /** Owner-qualified score declarations, for ir.scores emission. */
@@ -210,32 +258,91 @@ class Analyzer {
210
258
  * feedable's `hungry` — resolution is across the composer's trait set).
211
259
  */
212
260
  traitVisibleStates = new Map();
261
+ /**
262
+ * `define pronouns` sets by name (ADR-242 D7), collected in pass 1 so a
263
+ * `pronouns <word>` line resolves against sets declared later in the
264
+ * file. Span doubles as the pass-2 emission guard (family-channel
265
+ * precedent: an errored duplicate never emits a second entry).
266
+ */
267
+ pronounSetDecls = new Map();
213
268
  constructor(ast, diagnostics) {
214
269
  this.ast = ast;
215
270
  this.diagnostics = diagnostics;
216
271
  }
272
+ /** Extensions admitted by validated `use` lines (ADR-215). */
273
+ usedExtensions = new Set();
274
+ /**
275
+ * Phrasebooks in arbitration order (ADR-250 D3): header `use phrasebook`
276
+ * lines first (file position), then body `define phrasebook` blocks in
277
+ * declaration order. Conditions resolve in run() (pass 2), after every
278
+ * entity symbol exists.
279
+ */
280
+ phrasebookDecls = [];
281
+ /** Book name → declaring span (`analysis.duplicate-phrasebook` gate). */
282
+ phrasebookNames = new Map();
283
+ /**
284
+ * Story key → true when a predicate-less (default/always) book covers
285
+ * it. Book coverage counts as declaration for the missing-phrase gate
286
+ * (ADR-250 D4.6); predicated-only coverage earns the partial-coverage
287
+ * warning at first reference.
288
+ */
289
+ bookKeys = new Map();
290
+ partialCoverageWarned = new Set();
217
291
  run() {
218
292
  this.collect();
293
+ // ADR-215: validate `use` lines against the manifest registry — an
294
+ // unknown name is a compile error (the loader's trusted registry check
295
+ // backstops rogue IR), a duplicate is one-`use`-per-extension.
296
+ for (const use of this.ast.header?.uses ?? []) {
297
+ const manifest = index_js_1.EXTENSION_MANIFESTS.get(use.name);
298
+ if (!manifest) {
299
+ const gated = [...index_js_1.EXTENSION_MANIFESTS.values()].filter((m) => !m.core).map((m) => m.name);
300
+ this.diagnostics.error('analysis.unknown-extension', `\`use ${use.name}\` names no trusted extension — known: ${gated.join(', ')}.`, use.span);
301
+ }
302
+ else if (manifest.core) {
303
+ // ADR-215 Q4: NPC vocabulary is CORE — always on, never `use`d.
304
+ this.diagnostics.error('analysis.extension-core', `\`${use.name}\` vocabulary is core — it is always available; remove the \`use\` line.`, use.span);
305
+ }
306
+ else if (this.usedExtensions.has(use.name)) {
307
+ this.diagnostics.error('analysis.duplicate-use', `\`use ${use.name}\` is already declared — one \`use\` per extension.`, use.span);
308
+ }
309
+ else {
310
+ this.usedExtensions.add(use.name);
311
+ }
312
+ }
219
313
  const ir = {
220
- format: ir_1.IR_FORMAT,
314
+ format: ir_js_1.IR_FORMAT,
221
315
  meta: {
222
316
  title: this.ast.header?.title ?? '',
223
317
  author: this.ast.header?.author ?? '',
224
318
  fields: this.ast.header?.fields ?? {},
225
319
  },
320
+ uses: [...this.usedExtensions],
226
321
  story: {
227
322
  states: this.storyStates,
228
323
  reversible: this.ast.header?.statesReversible ?? false,
324
+ // Story-owned every-turn clauses (ADR-236 D7): built in STORY_SCOPE
325
+ // so `it` reports the unbound-referent gate; narration broadcasts
326
+ // (the story is everywhere — D11 satisfied trivially).
327
+ onClauses: (this.ast.header?.onClauses ?? []).map((c) => ({
328
+ ...this.buildOnClause(c, STORY_SCOPE),
329
+ narration: 'broadcast',
330
+ })),
229
331
  },
230
332
  entities: [],
231
333
  conditions: [],
232
334
  phrases: { defaultLocale: DEFAULT_LOCALE, locales: {} },
335
+ messageOverrides: { defaultLocale: DEFAULT_LOCALE, locales: {} },
336
+ phrasebooks: [],
233
337
  verbs: [],
234
338
  hatches: [],
235
339
  traits: [],
236
340
  actions: [],
237
341
  scores: this.scoreDecls,
238
342
  sequences: [],
343
+ machines: [],
344
+ channels: [],
345
+ pronounSets: [],
239
346
  hasHatches: false,
240
347
  };
241
348
  for (const decl of this.ast.declarations) {
@@ -262,6 +369,10 @@ class Analyzer {
262
369
  ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: 'text', span: decl.span });
263
370
  break;
264
371
  case 'define-hatch':
372
+ // ADR-094 chain hatch: the name must be a replaceable stdlib chain.
373
+ if (decl.hatchKind === 'chain' && !catalog_js_1.STDLIB_CHAIN_NAMES.has(decl.name)) {
374
+ this.diagnostics.error('analysis.unknown-chain', `\`${decl.name}\` is not a replaceable stdlib chain${this.suggestText(decl.name, [...catalog_js_1.STDLIB_CHAIN_NAMES])}.`, decl.span);
375
+ }
265
376
  ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: decl.hatchKind, span: decl.span });
266
377
  break;
267
378
  case 'define-trait':
@@ -270,6 +381,29 @@ class Analyzer {
270
381
  case 'define-action':
271
382
  ir.actions.push(this.buildAction(decl));
272
383
  break;
384
+ case 'define-machine':
385
+ ir.machines.push(this.buildMachine(decl));
386
+ break;
387
+ case 'define-asset':
388
+ break; // collected in pass 1 — data references, nothing to emit
389
+ case 'define-family-channel':
390
+ // ADR-241 D2/D4: the declaration joins the channel manifest.
391
+ // The pass-1 span guard keeps an errored duplicate from
392
+ // producing a second entry.
393
+ if (this.familyChannels[decl.family].get(decl.name) === decl.span) {
394
+ ir.channels.push({ name: decl.name, family: decl.family, span: decl.span });
395
+ }
396
+ break;
397
+ case 'define-channel':
398
+ ir.channels.push(this.buildChannel(decl));
399
+ break;
400
+ case 'define-pronouns':
401
+ // ADR-242 D7 — the pass-1 span guard keeps a shadowing/duplicate
402
+ // declaration from emitting (family-channel precedent).
403
+ if (this.pronounSetDecls.get(decl.name) === decl) {
404
+ ir.pronounSets.push(this.buildPronounSet(decl));
405
+ }
406
+ break;
273
407
  case 'define-sequence':
274
408
  ir.sequences.push({
275
409
  name: decl.name.join(' '),
@@ -295,18 +429,421 @@ class Analyzer {
295
429
  entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
296
430
  }
297
431
  break;
432
+ case 'override-message':
433
+ // ADR-255: same two-pass split as define-phrase — the optional
434
+ // `while <cond>` gate resolves here in pass 2. Only set when the
435
+ // alias survived pass-1 validation (an entry exists).
436
+ if (decl.condition) {
437
+ const entry = this.messageOverrides.get(DEFAULT_LOCALE)?.get(decl.alias);
438
+ if (entry)
439
+ entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
440
+ }
441
+ break;
442
+ case 'override-messages':
443
+ break; // flat entries collected in pass 1; no per-entry condition
298
444
  case 'define-phrases':
299
445
  break; // collected in pass 1
446
+ case 'define-phrasebook':
447
+ case 'import':
448
+ break; // collected/diagnosed in pass 1; conditions resolve below
449
+ case 'define-topics':
450
+ break; // applied onto owners after all entities are built (applyTopics)
300
451
  }
301
452
  }
453
+ // ADR-250 D3: books in arbitration order; predicates resolve here in
454
+ // pass 2 (an entity declared after the book may appear in its `while`).
455
+ ir.phrasebooks = this.phrasebookDecls.map((b) => ({
456
+ name: b.name,
457
+ source: b.source,
458
+ condition: b.condition ? this.resolveCondition(b.condition, TOP_SCOPE) : null,
459
+ ...(b.entries ? { entries: b.entries } : {}),
460
+ span: b.span,
461
+ }));
462
+ // ADR-241 D3/D4: the implied `main` ambient bed — used by an ambient
463
+ // statement without a declaration — joins the channel manifest so the
464
+ // loader registers it (nothing platform-side pre-registers ambient).
465
+ if (this.impliedMainBedSpan && !this.familyChannels.ambient.has('main')) {
466
+ ir.channels.push({ name: 'main', family: 'ambient', span: this.impliedMainBedSpan });
467
+ }
302
468
  for (const [locale, table] of this.phrases) {
303
469
  ir.phrases.locales[locale] = Object.fromEntries(table);
304
470
  }
471
+ for (const [locale, table] of this.messageOverrides) {
472
+ ir.messageOverrides.locales[locale] = Object.fromEntries(table);
473
+ }
305
474
  ir.hasHatches = ir.hatches.length > 0;
475
+ this.applyTopics(ir.entities);
476
+ this.checkRegions(ir.entities);
477
+ this.checkDoors(ir.entities);
306
478
  this.checkMarkers();
307
479
  this.checkDescriptionMarkers();
480
+ this.checkChannelReturns(ir.channels); // ADR-253 D1: return-field cross-check (all emits collected)
308
481
  return ir;
309
482
  }
483
+ /**
484
+ * ADR-236 D2/D3 never-guess gates over the whole region graph: member
485
+ * kinds (rooms or regions only), single direct membership (RoomTrait's
486
+ * `regionId` is single-valued — an ancestor+descendant listing is the
487
+ * same error), single parent per region, no memberless regions, no
488
+ * containment cycles. Runs after every entity is built so cross-entity
489
+ * lookups and spans are all available.
490
+ */
491
+ checkRegions(entities) {
492
+ const byId = new Map(entities.map((e) => [e.id, e]));
493
+ const isRegionEntity = (e) => e.kinds.some((k) => k.name === 'region');
494
+ const regions = entities.filter(isRegionEntity);
495
+ // Memberless region: declared-but-unanswerable, uniformly hard (D2 —
496
+ // ruled no warning tier; its daemon could otherwise silently never fire).
497
+ for (const region of regions) {
498
+ if (region.containing.length === 0) {
499
+ this.diagnostics.error('analysis.region-memberless', `Region \`${region.name}\` has no \`containing\` line — an empty region is unanswerable (its daemons and crossings could never fire). List its member rooms.`, region.span);
500
+ }
501
+ }
502
+ // Direct membership is stated exactly once, graph-wide.
503
+ const roomMemberOf = new Map();
504
+ const parentOf = new Map();
505
+ for (const region of regions) {
506
+ for (const member of region.containing) {
507
+ const target = byId.get(member.id);
508
+ if (!target)
509
+ continue; // unresolved — already reported by resolveEntityId
510
+ if (isRegionEntity(target)) {
511
+ const prior = parentOf.get(member.id);
512
+ if (prior) {
513
+ this.diagnostics.error('analysis.region-two-parents', `Region \`${target.name}\` is already contained by region \`${prior.parent.name}\` (line ${prior.span.line}) — a region has exactly one parent.`, member.span);
514
+ }
515
+ else {
516
+ parentOf.set(member.id, { parent: region, span: member.span });
517
+ }
518
+ }
519
+ else if (target.kinds.some((k) => k.name === 'room')) {
520
+ const prior = roomMemberOf.get(member.id);
521
+ if (prior) {
522
+ this.diagnostics.error('analysis.region-double-membership', `\`${target.name}\` is already a member of region \`${prior.region.name}\` (line ${prior.span.line}) — direct membership is stated exactly once (nesting already makes a room part of every ancestor region).`, member.span);
523
+ }
524
+ else {
525
+ roomMemberOf.set(member.id, { region, span: member.span });
526
+ }
527
+ }
528
+ else {
529
+ const kind = target.kinds[0]?.name ?? 'plain thing';
530
+ this.diagnostics.error('analysis.region-member-kind', `\`${target.name}\` is a ${kind} — \`containing\` members must be rooms or regions.`, member.span);
531
+ }
532
+ }
533
+ }
534
+ // Containment cycles (walking child → parent; two-parents kept the
535
+ // first edge, so the graph is functional and one walk per region ends).
536
+ const walked = new Map();
537
+ for (const region of regions) {
538
+ if (walked.has(region.id))
539
+ continue;
540
+ const path = [];
541
+ let cur = region.id;
542
+ while (cur !== undefined && !walked.has(cur)) {
543
+ walked.set(cur, 'visiting');
544
+ path.push(cur);
545
+ const edge = parentOf.get(cur);
546
+ const next = edge?.parent.id;
547
+ if (next !== undefined && walked.get(next) === 'visiting') {
548
+ const names = [...path.slice(path.indexOf(next)), next].map((id) => byId.get(id)?.name ?? id);
549
+ this.diagnostics.error('analysis.region-cycle', `Region containment cycle: ${names.map((n) => `\`${n}\``).join(' → ')}.`, edge.span);
550
+ break;
551
+ }
552
+ cur = next;
553
+ }
554
+ for (const id of path)
555
+ walked.set(id, 'done');
556
+ }
557
+ }
558
+ /**
559
+ * ADR-234 D3 never-guess gates over the whole door graph: every
560
+ * `through` target is a door, a door connects exactly one room pair
561
+ * (the only legal second reference is the exact mirror — other side,
562
+ * opposite direction), and every declared door is referenced somewhere
563
+ * (an unconnected door is unanswerable, same hard class as a
564
+ * memberless region). Runs after every entity is built so cross-entity
565
+ * lookups and spans are all available.
566
+ */
567
+ checkDoors(entities) {
568
+ const byId = new Map(entities.map((e) => [e.id, e]));
569
+ const isDoorEntity = (e) => e.kinds.some((k) => k.name === 'door');
570
+ // First `through` reference per door — the canonical pair.
571
+ const firstRef = new Map();
572
+ // Doors whose mirror side has already been stated.
573
+ const mirrored = new Set();
574
+ for (const owner of entities) {
575
+ for (const exit of owner.exits) {
576
+ if (exit.via === null || exit.via === '')
577
+ continue; // plain, or unresolved (already reported)
578
+ const door = byId.get(exit.via);
579
+ if (!door)
580
+ continue;
581
+ if (!isDoorEntity(door)) {
582
+ const kind = door.kinds[0]?.name ?? 'plain thing';
583
+ this.diagnostics.error('analysis.door-through-kind', `\`${door.name}\` is a ${kind} — \`through\` names a door (\`create the ${door.name} / a door\`).`, exit.span);
584
+ continue;
585
+ }
586
+ const first = firstRef.get(door.id);
587
+ if (!first) {
588
+ firstRef.set(door.id, { owner, exit });
589
+ continue;
590
+ }
591
+ const samePair = (owner.id === first.owner.id && exit.to === first.exit.to)
592
+ || (owner.id === first.exit.to && exit.to === first.owner.id);
593
+ if (!samePair) {
594
+ this.diagnostics.error('analysis.door-multi-pair', `\`${door.name}\` already connects \`${first.owner.name}\` and \`${byId.get(first.exit.to)?.name ?? first.exit.to}\` (line ${first.exit.span.line}) — a door connects exactly two rooms.`, exit.span);
595
+ continue;
596
+ }
597
+ const isMirror = owner.id === first.exit.to
598
+ && exit.to === first.owner.id
599
+ && exit.direction === OPPOSITE_DIRECTION[first.exit.direction]
600
+ && !mirrored.has(door.id);
601
+ if (isMirror) {
602
+ mirrored.add(door.id);
603
+ }
604
+ else {
605
+ this.diagnostics.error('analysis.door-pair-mismatch', `\`${door.name}\` is already wired by \`${first.owner.name}\`'s \`${first.exit.direction}\` line (line ${first.exit.span.line}) — the only legal second reference is the exact mirror (\`${OPPOSITE_DIRECTION[first.exit.direction] ?? '?'} to the ${first.owner.name} through the ${door.name}\` in \`${byId.get(first.exit.to)?.name ?? first.exit.to}\`), stated at most once.`, exit.span);
606
+ }
607
+ }
608
+ }
609
+ // Plain mirror of a door exit (platform-issue-sweep Phase 8 #6): a
610
+ // plain `<dir> to <room>` line whose reverse side is door-wired would
611
+ // re-stamp BOTH directions without the door at load, silently unwiring
612
+ // it (the loader's connectRooms stamps the reverse too). The author
613
+ // must name the door — or drop the line entirely, since one door-wired
614
+ // side already connects both rooms.
615
+ for (const owner of entities) {
616
+ for (const exit of owner.exits) {
617
+ if (exit.via !== null)
618
+ continue;
619
+ const target = byId.get(exit.to);
620
+ if (!target)
621
+ continue;
622
+ const reverse = target.exits.find((e) => e.via !== null && e.via !== '' && e.to === owner.id
623
+ && e.direction === OPPOSITE_DIRECTION[exit.direction]);
624
+ if (reverse) {
625
+ const doorName = byId.get(reverse.via)?.name ?? reverse.via;
626
+ this.diagnostics.error('analysis.door-plain-mirror', `\`${owner.name}\`'s plain \`${exit.direction}\` line mirrors a door exit — \`${target.name}\` wires \`${reverse.direction}\` through \`${doorName}\` (line ${reverse.span.line}), and a plain mirror would silently unwire the door at load. Name the door (\`${exit.direction} to the ${target.name} through the ${doorName}\`) or drop this line (the door side already connects both rooms).`, exit.span);
627
+ }
628
+ }
629
+ }
630
+ // Unconnected door: declared-but-unanswerable, uniformly hard (D3 —
631
+ // same class as region-memberless; its room pair could never resolve).
632
+ for (const door of entities.filter(isDoorEntity)) {
633
+ if (!firstRef.has(door.id)) {
634
+ this.diagnostics.error('analysis.door-unconnected', `Door \`${door.name}\` is never referenced by a \`through\` exit line — an unconnected door is unanswerable (its room pair could never resolve). Add \`<direction> to the <room> through the ${door.name}\` on a room.`, door.span);
635
+ }
636
+ }
637
+ }
638
+ /**
639
+ * ADR-239 D4's never-guess table gates + lowering: resolve each
640
+ * `define topics` block onto its owner entity's `topics` rows. Runs
641
+ * after every entity is built so owner kinds and cross-tier name
642
+ * lookups are all available. Gates (each its own diagnostic): a second
643
+ * block for the same owner, a non-person host, a duplicate topic
644
+ * (entity or normalized quoted text, aliases included), and a quoted
645
+ * entry colliding with the name/aka of an entity used in an
646
+ * entity-tier row of the same table.
647
+ */
648
+ applyTopics(entities) {
649
+ const byId = new Map(entities.map((e) => [e.id, e]));
650
+ /** owner id → first block's span (duplicate-block gate). */
651
+ const blockOwners = new Map();
652
+ for (const decl of this.ast.declarations) {
653
+ if (decl.kind !== 'define-topics')
654
+ continue;
655
+ if (decl.owner.words.length === 0)
656
+ continue; // header parse error already reported
657
+ const ownerId = this.resolveEntityId(decl.owner);
658
+ if (!ownerId)
659
+ continue; // unknown/ambiguous — standard errors already reported
660
+ const owner = byId.get(ownerId);
661
+ const sym = this.byId.get(ownerId) ?? null;
662
+ if (!owner)
663
+ continue;
664
+ const first = blockOwners.get(ownerId);
665
+ if (first) {
666
+ this.diagnostics.error('analysis.duplicate-topics-block', `\`${owner.name}\` already has a \`define topics\` block at line ${first.line} — the table lives in one place; merge the rows.`, decl.span);
667
+ continue;
668
+ }
669
+ blockOwners.set(ownerId, decl.span);
670
+ if (!owner.kinds.some((k) => k.name === 'person')) {
671
+ const kind = owner.kinds[0] ? `a ${owner.kinds[0].name}` : 'a plain thing';
672
+ this.diagnostics.error('analysis.topics-host', `\`define topics\` needs a person — \`${owner.name}\` is ${kind}, and only people answer \`ask\`/\`tell\` (a table here could never be reached).`, decl.span);
673
+ continue;
674
+ }
675
+ // Entity-tier refs resolve once, up front: the cross-tier collision
676
+ // gate must see every entity-tier row's names, even those declared
677
+ // AFTER a colliding quoted row.
678
+ const resolvedRefs = decl.rows.map((row) => row.filter.kind === 'entity' ? this.resolveEntityId(row.filter.ref) : null);
679
+ /** normalized name/aka of entity-tier row entities → display name. */
680
+ const entityTierNames = new Map();
681
+ for (const id of resolvedRefs) {
682
+ if (!id)
683
+ continue;
684
+ const rowSym = this.byId.get(id);
685
+ if (!rowSym)
686
+ continue;
687
+ entityTierNames.set(normalizeTopic(rowSym.nameLower), rowSym.nameLower);
688
+ for (const alias of rowSym.aka)
689
+ entityTierNames.set(normalizeTopic(alias), rowSym.nameLower);
690
+ }
691
+ const scope = entityScope(sym);
692
+ const rows = [];
693
+ const seenEntities = new Map();
694
+ const seenTexts = new Map();
695
+ for (let i = 0; i < decl.rows.length; i++) {
696
+ const row = decl.rows[i];
697
+ if (row.filter.kind === 'entity') {
698
+ const id = resolvedRefs[i];
699
+ if (!id)
700
+ continue; // unresolved — already reported
701
+ const dup = seenEntities.get(id);
702
+ if (dup) {
703
+ this.diagnostics.error('analysis.duplicate-topic', `\`${this.byId.get(id)?.nameLower ?? id}\` is already a topic of this table (line ${dup.line}) — a topic answers in one place; merge the rows.`, row.span);
704
+ continue;
705
+ }
706
+ seenEntities.set(id, row.span);
707
+ rows.push({
708
+ filter: { kind: 'entity', id },
709
+ body: row.body.map((s) => this.resolveStatement(s, scope)),
710
+ span: row.span,
711
+ });
712
+ }
713
+ else {
714
+ const texts = [row.filter.primary, ...row.filter.aliases];
715
+ let rejected = false;
716
+ for (const text of texts) {
717
+ const norm = normalizeTopic(text);
718
+ const dup = seenTexts.get(norm);
719
+ if (dup) {
720
+ this.diagnostics.error('analysis.duplicate-topic', `"${text}" is already declared in this table (line ${dup.line}) — aliases included, a topic answers in one place.`, row.filter.span);
721
+ rejected = true;
722
+ continue;
723
+ }
724
+ seenTexts.set(norm, row.span);
725
+ const collidesWith = entityTierNames.get(norm);
726
+ if (collidesWith) {
727
+ this.diagnostics.error('analysis.topic-entity-collision', `"${text}" collides with \`${collidesWith}\` — that entity is already an entity-tier row of this table, and the quoted spelling would shadow its quiet entity resolution. Remove one.`, row.filter.span);
728
+ rejected = true;
729
+ }
730
+ }
731
+ if (rejected)
732
+ continue;
733
+ rows.push({
734
+ filter: { kind: 'text', primary: row.filter.primary, aliases: row.filter.aliases },
735
+ body: row.body.map((s) => this.resolveStatement(s, scope)),
736
+ span: row.span,
737
+ });
738
+ }
739
+ this.checkPhaseOrder(row.body, { mutated: false });
740
+ }
741
+ owner.topics = rows;
742
+ }
743
+ }
744
+ /** Machine names seen (duplicate gate). */
745
+ machineNames = new Set();
746
+ /** Declared media assets (ADR-216): name → kind + path. */
747
+ assets = new Map();
748
+ /**
749
+ * Build one `define machine` (ADR-215 `use state-machines` depth):
750
+ * gated on the `use`, states/targets/roles validated, single-word
751
+ * triggers resolved (declared condition or story state wins, else an
752
+ * action gerund), bodies in STORY_SCOPE (`it` unbound — the machine is
753
+ * story-owned).
754
+ */
755
+ buildMachine(decl) {
756
+ if (!this.usedExtensions.has('state-machines')) {
757
+ this.diagnostics.error('analysis.extension-not-used', '`define machine` is `state-machines` extension vocabulary — add `use state-machines` to the story header.', decl.span);
758
+ }
759
+ const name = decl.name.join(' ');
760
+ if (this.machineNames.has(name)) {
761
+ this.diagnostics.error('analysis.duplicate-machine', `A machine named \`${name}\` already exists.`, decl.span);
762
+ }
763
+ this.machineNames.add(name);
764
+ const stateNames = new Set(decl.states.map((s) => s.name));
765
+ if (decl.states.length === 0) {
766
+ this.diagnostics.error('analysis.machine-states', `Machine \`${name}\` declares no states.`, decl.span);
767
+ }
768
+ if (decl.initialState === null) {
769
+ this.diagnostics.error('analysis.machine-starts', `Machine \`${name}\` needs a \`starts <state>\` line.`, decl.span);
770
+ }
771
+ else if (!stateNames.has(decl.initialState)) {
772
+ this.diagnostics.error('analysis.machine-starts', `\`starts ${decl.initialState}\` names no declared state of \`${name}\`${this.suggestText(decl.initialState, [...stateNames])}.`, decl.span);
773
+ }
774
+ const roles = new Map();
775
+ for (const role of decl.roles) {
776
+ if (roles.has(role.name)) {
777
+ this.diagnostics.error('analysis.machine-role', `Role \`${role.name}\` is declared twice on \`${name}\`.`, role.span);
778
+ continue;
779
+ }
780
+ const entity = this.resolveEntityId(role.entity);
781
+ if (entity !== null)
782
+ roles.set(role.name, entity);
783
+ }
784
+ const buildTransition = (t) => {
785
+ if (!stateNames.has(t.target)) {
786
+ this.diagnostics.error('analysis.machine-target', `\`${t.target}\` names no declared state of \`${name}\`${this.suggestText(t.target, [...stateNames])}.`, t.span);
787
+ }
788
+ let trigger;
789
+ switch (t.trigger.kind) {
790
+ case 'event':
791
+ trigger = { kind: 'event', event: t.trigger.event };
792
+ break;
793
+ case 'condition':
794
+ trigger = { kind: 'condition', condition: this.resolveCondition(t.trigger.condition, STORY_SCOPE) };
795
+ break;
796
+ case 'word': {
797
+ // Vocabulary-free parse: a declared condition or story state is a
798
+ // condition trigger; anything else is an action gerund.
799
+ if (this.conditionNames.has(t.trigger.word) || this.storyStates.includes(t.trigger.word)) {
800
+ trigger = {
801
+ kind: 'condition',
802
+ condition: this.resolveCondition({ kind: 'condition-ref', name: t.trigger.word, span: t.trigger.span }, STORY_SCOPE),
803
+ };
804
+ }
805
+ else {
806
+ trigger = { kind: 'action', action: t.trigger.word, target: null };
807
+ }
808
+ break;
809
+ }
810
+ case 'action': {
811
+ let target = null;
812
+ if (t.trigger.target) {
813
+ const words = t.trigger.target.words.map((w) => w.toLowerCase());
814
+ if (words.length === 1 && roles.has(words[0])) {
815
+ target = `$${words[0]}`; // the platform binding convention
816
+ }
817
+ else {
818
+ target = this.resolveEntityId(t.trigger.target);
819
+ }
820
+ }
821
+ trigger = { kind: 'action', action: t.trigger.action, target };
822
+ break;
823
+ }
824
+ }
825
+ return {
826
+ trigger,
827
+ condition: t.condition ? this.resolveCondition(t.condition, STORY_SCOPE) : null,
828
+ target: t.target,
829
+ span: t.span,
830
+ };
831
+ };
832
+ return {
833
+ name,
834
+ roles: [...roles].map(([roleName, entity]) => ({ name: roleName, entity })),
835
+ initialState: decl.initialState ?? decl.states[0]?.name ?? '',
836
+ states: decl.states.map((s) => ({
837
+ name: s.name,
838
+ terminal: s.terminal,
839
+ transitions: s.transitions.map(buildTransition),
840
+ onEnter: s.onEnter.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
841
+ onExit: s.onExit.map((stmt) => this.resolveStatement(stmt, STORY_SCOPE)),
842
+ span: s.span,
843
+ })),
844
+ span: decl.span,
845
+ };
846
+ }
310
847
  /** Resolve a `when <owner> becomes <state>` step anchor (ratchet D10). */
311
848
  resolveStepAnchor(step) {
312
849
  if (step.timing !== 'becomes' || !step.owner || !step.state)
@@ -377,7 +914,7 @@ class Analyzer {
377
914
  if (clause.binding === 'every-turn')
378
915
  continue;
379
916
  let key = `${clause.clauseKind}|${clause.action}|${clause.binding}|${clause.role ?? ''}`;
380
- if (catalog_1.EVENT_VERBS.has(clause.action)) {
917
+ if (catalog_js_1.EVENT_VERBS.has(clause.action)) {
381
918
  key += `|${clause.condition ? conditionFingerprint(clause.condition) : ''}`;
382
919
  }
383
920
  const first = seen.get(key);
@@ -446,6 +983,16 @@ class Analyzer {
446
983
  this.storyStates = this.ast.header.states.map((s) => s.name);
447
984
  for (const s of this.ast.header.scores)
448
985
  this.collectScore(s.name, s.worth, s.span, null);
986
+ // Header `on every turn` clause bodies host inline phrase prose like
987
+ // every other body context (platform-issue-sweep Phase 8 #14): story-
988
+ // owned, so ownerless/bare-key scope — the same registration the five
989
+ // pre-existing contexts use.
990
+ for (const clause of this.ast.header.onClauses)
991
+ this.collectInlineTexts(clause.body);
992
+ // `use phrasebook` lines (ADR-250 D2/D3) — header position puts every
993
+ // used book ahead of body-declared books in the arbitration order.
994
+ for (const use of this.ast.header.usePhrasebooks)
995
+ this.collectUsePhrasebook(use);
449
996
  }
450
997
  for (const decl of this.ast.declarations) {
451
998
  if (decl.kind === 'create')
@@ -466,6 +1013,50 @@ class Analyzer {
466
1013
  this.collectPhrasesBlock(decl);
467
1014
  else if (decl.kind === 'define-phrase')
468
1015
  this.collectPhraseDecl(decl);
1016
+ else if (decl.kind === 'define-phrasebook')
1017
+ this.collectPhrasebook(decl);
1018
+ else if (decl.kind === 'override-message')
1019
+ this.collectOverrideMessage(decl);
1020
+ else if (decl.kind === 'override-messages')
1021
+ this.collectOverrideMessages(decl);
1022
+ else if (decl.kind === 'import') {
1023
+ // The compile host resolves imports before analysis (splicing the
1024
+ // fragment's declarations at this position); one surviving here means
1025
+ // no resolver ran — direct parse/analyze callers included.
1026
+ this.diagnostics.error('analysis.import-unresolved', `\`import "${decl.path}"\` was not resolved — compile with an \`importResolver\` host hook.`, decl.span);
1027
+ }
1028
+ else if (decl.kind === 'define-asset') {
1029
+ // ADR-216: declared media assets — DATA references, one namespace.
1030
+ if (this.assets.has(decl.name)) {
1031
+ this.diagnostics.error('analysis.duplicate-asset', `An asset named \`${decl.name}\` already exists.`, decl.span);
1032
+ }
1033
+ else {
1034
+ this.assets.set(decl.name, { kind: decl.assetKind, path: decl.path, span: decl.span });
1035
+ }
1036
+ }
1037
+ else if (decl.kind === 'define-family-channel') {
1038
+ // ADR-241 D2: named family channels, per-family namespace.
1039
+ const family = this.familyChannels[decl.family];
1040
+ if (family.has(decl.name)) {
1041
+ this.diagnostics.error('analysis.duplicate-channel', `An ${decl.family === 'ambient' ? 'ambient bed' : 'image layer'} named \`${decl.name}\` is already declared.`, decl.span);
1042
+ }
1043
+ else {
1044
+ family.set(decl.name, decl.span);
1045
+ }
1046
+ }
1047
+ else if (decl.kind === 'define-pronouns') {
1048
+ // ADR-242 D7: one namespace beside the standard four — shadowing a
1049
+ // standard word and redefining a set are each errors, never a merge.
1050
+ if (catalog_js_1.PRONOUN_WORDS.has(decl.name)) {
1051
+ this.diagnostics.error('analysis.pronoun-set-shadows', `\`${decl.name}\` is a standard pronoun set — \`define pronouns\` names a new set; pick another name.`, decl.span);
1052
+ }
1053
+ else if (this.pronounSetDecls.has(decl.name)) {
1054
+ this.diagnostics.error('analysis.duplicate-pronoun-set', `A pronoun set named \`${decl.name}\` is already defined.`, decl.span);
1055
+ }
1056
+ else {
1057
+ this.pronounSetDecls.set(decl.name, decl);
1058
+ }
1059
+ }
469
1060
  else if (decl.kind === 'define-trait') {
470
1061
  this.traitNames.add(decl.name);
471
1062
  for (const field of decl.data) {
@@ -512,6 +1103,15 @@ class Analyzer {
512
1103
  for (const step of decl.steps)
513
1104
  this.collectInlineTexts(step.body);
514
1105
  }
1106
+ else if (decl.kind === 'define-machine') {
1107
+ // Machine state bodies (`on enter` / `on exit`) host inline phrase
1108
+ // prose like every other body context (platform-issue-sweep Phase 8
1109
+ // #14) — ownerless/bare-key scope, matching the header clauses.
1110
+ for (const state of decl.states) {
1111
+ this.collectInlineTexts(state.onEnter);
1112
+ this.collectInlineTexts(state.onExit);
1113
+ }
1114
+ }
515
1115
  }
516
1116
  // Trait-declared states reach every composer (ratchet D8): merge each
517
1117
  // trait's state set into the composing entity's, in composition order.
@@ -613,6 +1213,37 @@ class Analyzer {
613
1213
  for (const clause of e.decl.onClauses)
614
1214
  this.collectInlineTexts(clause.body, e.id);
615
1215
  }
1216
+ // Topic-table row bodies are entity-owned (ADR-239): their inline
1217
+ // phrases register owner-scoped like clause bodies. Owner resolution
1218
+ // here is silent — an unresolved owner is pass 2's error (applyTopics).
1219
+ for (const decl of this.ast.declarations) {
1220
+ if (decl.kind !== 'define-topics')
1221
+ continue;
1222
+ const owner = this.findEntitySilent(decl.owner);
1223
+ if (!owner)
1224
+ continue;
1225
+ for (const row of decl.rows)
1226
+ this.collectInlineTexts(row.body, owner.id);
1227
+ }
1228
+ }
1229
+ /**
1230
+ * Resolve a name to an entity symbol with resolveEntityId's exact
1231
+ * precedence (exact name → alias → unique in-order subset) but NO
1232
+ * diagnostics — for pass-1 uses where pass 2 will report the miss.
1233
+ */
1234
+ findEntitySilent(ref) {
1235
+ const lower = ref.words.join(' ').toLowerCase();
1236
+ const exact = this.entities.filter((e) => e.nameLower === lower);
1237
+ if (exact.length === 1)
1238
+ return exact[0];
1239
+ const byAlias = this.entities.filter((e) => e.aka.includes(lower));
1240
+ if (byAlias.length === 1)
1241
+ return byAlias[0];
1242
+ const refWords = ref.words.map((w) => w.toLowerCase());
1243
+ const subset = this.entities.filter((e) => isInOrderSubset(refWords, e.nameWords));
1244
+ if (subset.length === 1)
1245
+ return subset[0];
1246
+ return null;
616
1247
  }
617
1248
  /**
618
1249
  * Declare-and-emit sugar (§2.6): a `phrase <key>` statement carrying an
@@ -667,7 +1298,7 @@ class Analyzer {
667
1298
  this.diagnostics.error('analysis.boolean-state', `\`${s.name}\` is a boolean literal, not a state — booleans are not part of Chord at any scope (given 8). Name what ${ownerDesc} IS in that condition.`, s.span);
668
1299
  }
669
1300
  }
670
- for (const { pair, trait } of catalog_1.PLATFORM_STATE_PAIRS) {
1301
+ for (const { pair, trait } of catalog_js_1.PLATFORM_STATE_PAIRS) {
671
1302
  if (names.includes(pair[0]) && names.includes(pair[1])) {
672
1303
  const at = states.find((s) => s.name === pair[1]) ?? states[0];
673
1304
  this.diagnostics.error('analysis.shadow-state', `\`${pair[0]}\`/\`${pair[1]}\` reproduces a platform-owned pair — compose \`${trait}\` and read the state live (\`is ${pair[0]}\`) instead of storing it.`, at.span);
@@ -779,9 +1410,111 @@ class Analyzer {
779
1410
  span: decl.span,
780
1411
  });
781
1412
  }
1413
+ /** ADR-255: `override message <alias>` — full phrase body under an ACL alias. */
1414
+ collectOverrideMessage(decl) {
1415
+ this.registerMessageOverride(DEFAULT_LOCALE, decl.alias, decl.span, {
1416
+ strategy: decl.strategy ?? null,
1417
+ ...(decl.verbatim ? { verbatim: true } : {}),
1418
+ variants: decl.variants.map((v) => this.variantOf(v)),
1419
+ span: decl.span,
1420
+ });
1421
+ }
1422
+ /** ADR-255: `override messages <locale>` — flat `alias: text` entries. */
1423
+ collectOverrideMessages(decl) {
1424
+ for (const entry of decl.entries) {
1425
+ this.registerMessageOverride(decl.locale, entry.key, entry.span, {
1426
+ strategy: null,
1427
+ variants: [this.variantOf(entry.value)],
1428
+ span: entry.span,
1429
+ });
1430
+ }
1431
+ }
1432
+ /**
1433
+ * ADR-255 D4: register a message override, gating the alias against the
1434
+ * ACL catalog (`analysis.unknown-message-alias`, mirroring `unknown-channel`)
1435
+ * and rejecting a duplicate. The alias — never a dotted platform id — is
1436
+ * resolved to `if.action.*` loader-side (Interface Contract 3).
1437
+ */
1438
+ registerMessageOverride(locale, alias, span, phrase) {
1439
+ if (!alias)
1440
+ return; // header parse error already reported
1441
+ if (!catalog_js_1.MESSAGE_OVERRIDE_ALIASES.has(alias)) {
1442
+ this.diagnostics.error('analysis.unknown-message-alias', `\`${alias}\` is not a standard-action message alias${this.suggestText(alias, [...catalog_js_1.MESSAGE_OVERRIDE_ALIASES])}.`, span);
1443
+ return;
1444
+ }
1445
+ let table = this.messageOverrides.get(locale);
1446
+ if (!table) {
1447
+ table = new Map();
1448
+ this.messageOverrides.set(locale, table);
1449
+ }
1450
+ if (table.has(alias)) {
1451
+ this.diagnostics.error('analysis.duplicate-message-override', `Message override \`${alias}\` is declared twice in ${locale}.`, span);
1452
+ return;
1453
+ }
1454
+ table.set(alias, phrase);
1455
+ }
782
1456
  variantOf(value) {
783
1457
  return { text: value.text, markers: value.markers.map((m) => m.content) };
784
1458
  }
1459
+ /** Duplicate-name gate shared by `define phrasebook` and `use phrasebook`. */
1460
+ registerPhrasebookName(name, span) {
1461
+ const first = this.phrasebookNames.get(name);
1462
+ if (first) {
1463
+ this.diagnostics.error('analysis.duplicate-phrasebook', `A phrasebook named \`${name}\` is already declared at line ${first.line}.`, span);
1464
+ return false;
1465
+ }
1466
+ this.phrasebookNames.set(name, span);
1467
+ return true;
1468
+ }
1469
+ /** Book coverage bookkeeping: key → covered by a default (always) book? */
1470
+ recordBookKey(key, isAlways) {
1471
+ this.bookKeys.set(key, (this.bookKeys.get(key) ?? false) || isAlways);
1472
+ }
1473
+ /** `use phrasebook <name> [while <cond>]` (ADR-250 D2/D3) — pass 1. */
1474
+ collectUsePhrasebook(use) {
1475
+ const manifest = phrasebooks_js_1.PHRASEBOOK_REGISTRY.get(use.name);
1476
+ if (!manifest) {
1477
+ this.diagnostics.error('analysis.unknown-phrasebook', `\`use phrasebook ${use.name}\` names no registered phrasebook${this.suggestText(use.name, [...phrasebooks_js_1.PHRASEBOOK_REGISTRY.keys()])}.`, use.span);
1478
+ return;
1479
+ }
1480
+ if (!this.registerPhrasebookName(use.name, use.span))
1481
+ return;
1482
+ this.phrasebookDecls.push({ name: use.name, source: 'use', condition: use.condition, span: use.span });
1483
+ for (const key of manifest.keys)
1484
+ this.recordBookKey(key, use.condition === null);
1485
+ }
1486
+ /** `define phrasebook` (ADR-250 D1/D3) — pass 1: entry gates + coverage. */
1487
+ collectPhrasebook(decl) {
1488
+ if (!decl.name)
1489
+ return; // header parse error already reported
1490
+ if (!this.registerPhrasebookName(decl.name, decl.span))
1491
+ return;
1492
+ const entries = {};
1493
+ for (const entry of decl.entries) {
1494
+ if (entry.condition) {
1495
+ this.diagnostics.error('analysis.phrasebook-entry-gate', `Entries carry no \`while\` — the book's own predicate is the gate. Move the condition to \`define phrasebook ${decl.name} while …\`, or split the entry into a second book.`, entry.span);
1496
+ }
1497
+ if (entry.key.includes('.')) {
1498
+ this.diagnostics.error('analysis.phrasebook-dotted-key', `\`${entry.key}\` is a platform message ID — phrasebooks voice the story's own keys. To override a platform message, use a story-level \`define phrase ${entry.key}\`.`, entry.span);
1499
+ continue;
1500
+ }
1501
+ if (entry.key === 'br' || RESERVED_CHANNEL_KEYS.has(entry.key)) {
1502
+ this.diagnostics.error('analysis.phrasebook-reserved-key', `\`${entry.key}\` is reserved — pick another entry key.`, entry.span);
1503
+ continue;
1504
+ }
1505
+ if (entries[entry.key]) {
1506
+ this.diagnostics.error('analysis.phrasebook-duplicate-key', `\`${entry.key}\` is declared twice in phrasebook \`${decl.name}\` — competing texts live in different books.`, entry.span);
1507
+ continue;
1508
+ }
1509
+ entries[entry.key] = {
1510
+ strategy: entry.strategy ?? null,
1511
+ variants: entry.variants.map((v) => this.variantOf(v)),
1512
+ span: entry.span,
1513
+ };
1514
+ this.recordBookKey(entry.key, decl.condition === null);
1515
+ }
1516
+ this.phrasebookDecls.push({ name: decl.name, source: 'define', condition: decl.condition, entries, span: decl.span });
1517
+ }
785
1518
  registerPhrase(locale, key, phrase) {
786
1519
  if (key === 'br') {
787
1520
  // `{br}` is the built-in hard-line-break marker (grammar log 2026-07-10).
@@ -816,7 +1549,16 @@ class Analyzer {
816
1549
  for (const comp of decl.compositions) {
817
1550
  const built = {
818
1551
  name: comp.words.join(' ').toLowerCase(),
819
- config: comp.config.map((c) => ({ key: c.key.join(' '), value: c.value, valueKind: c.valueKind })),
1552
+ config: comp.config.map((c) => ({
1553
+ key: c.key.join(' '),
1554
+ value: c.value,
1555
+ valueKind: c.valueKind,
1556
+ // `[ … ]` list entries resolve to entity IDs here (ADR-215) —
1557
+ // unresolved names report through the standard unknown-entity gate.
1558
+ ...(c.valueKind === 'list'
1559
+ ? { values: (c.listValues ?? []).map((ref) => this.resolveEntityId(ref) ?? '').filter((id) => id !== '') }
1560
+ : {}),
1561
+ })),
820
1562
  condition: comp.condition ? this.resolveCondition(comp.condition, entityScope(sym ?? null)) : null,
821
1563
  span: comp.span,
822
1564
  };
@@ -824,6 +1566,128 @@ class Analyzer {
824
1566
  kinds.push(built);
825
1567
  else
826
1568
  traits.push(built);
1569
+ // ADR-215: extension vocabulary is admitted only when its `use` is
1570
+ // declared (core manifests — npc — are always admitted), and its
1571
+ // `with`-fields are the manifest's closed, typed set — unknown keys
1572
+ // and mistyped values are compile errors, never a silent drop at the
1573
+ // loader. `[ … ]` list values exist only as manifest list fields.
1574
+ if (!comp.article) {
1575
+ const contributed = (0, index_js_1.manifestForAdjective)(built.name);
1576
+ if (!contributed) {
1577
+ for (const cfg of comp.config) {
1578
+ if (cfg.valueKind === 'list') {
1579
+ this.diagnostics.error('analysis.config-list-host', `\`[ … ]\` list values belong to extension fields that declare them (e.g. \`patrol route [ … ]\`) — \`${built.name}\` has none.`, cfg.span);
1580
+ }
1581
+ }
1582
+ }
1583
+ if (contributed) {
1584
+ if (!contributed.manifest.core && !this.usedExtensions.has(contributed.manifest.name)) {
1585
+ this.diagnostics.error('analysis.extension-not-used', `\`${built.name}\` is \`${contributed.manifest.name}\` extension vocabulary — add \`use ${contributed.manifest.name}\` to the story header.`, comp.span);
1586
+ }
1587
+ else {
1588
+ for (const cfg of comp.config) {
1589
+ const key = cfg.key.join(' ');
1590
+ const field = contributed.adjective.fields.find((f) => f.key === key);
1591
+ if (!field) {
1592
+ const known = contributed.adjective.fields.map((f) => f.key).join(', ');
1593
+ this.diagnostics.error('analysis.extension-config-key', `\`${key}\` is not a \`${built.name}\` field — known fields: ${known}.`, cfg.span);
1594
+ }
1595
+ else if (field.valueKind !== cfg.valueKind) {
1596
+ this.diagnostics.error('analysis.extension-config-value', `\`${key}\` takes a ${field.valueKind} value, not ${cfg.valueKind === 'name' ? 'an entity name' : `a ${cfg.valueKind}`}.`, cfg.span);
1597
+ }
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ }
1603
+ // ADR-231 D5a pairing gate: each `starts <state>` initializer requires
1604
+ // its paired trait composed on the same entity (`starts locked` needs
1605
+ // `lockable`, `starts closed`/`open` need `openable`, `starts off`/`on`
1606
+ // need `switchable`). Table-driven — STARTS_STATE_PAIRINGS is the one
1607
+ // place future stateful traits extend. Mismatch = load-time error, never
1608
+ // a silent no-op.
1609
+ const startsStates = [];
1610
+ for (const s of decl.startsStates) {
1611
+ const requiredTrait = catalog_js_1.STARTS_STATE_PAIRINGS.get(s.state);
1612
+ if (!requiredTrait)
1613
+ continue; // parser already rejected the word
1614
+ if (!traits.some((t) => t.name === requiredTrait)) {
1615
+ this.diagnostics.error('analysis.starts-state-pairing', `\`starts ${s.state}\` requires \`${requiredTrait}\` composed on this entity.`, s.span);
1616
+ continue;
1617
+ }
1618
+ startsStates.push(s.state);
1619
+ }
1620
+ // ADR-242 D1: `proper` is the first kind-scoped trait adjective —
1621
+ // person-only and unconditional (identity is not turn state). Both
1622
+ // gates are analyzer diagnostics so the author reads the specific
1623
+ // reason, not the loader's generic conditional-composition error.
1624
+ const isPerson = kinds.some((k) => k.name === 'person');
1625
+ for (const comp of decl.compositions) {
1626
+ if (comp.article || comp.words.join(' ').toLowerCase() !== 'proper')
1627
+ continue;
1628
+ if (!isPerson) {
1629
+ this.diagnostics.error('analysis.proper-person-only', `\`proper\` composes only on a person (\`a person, proper\`) — \`${decl.name.words.join(' ')}\` is not a person.`, comp.span);
1630
+ }
1631
+ if (comp.condition) {
1632
+ this.diagnostics.error('analysis.proper-conditional', 'Identity is not conditional — `proper while …` is not supported; a name is proper or it is not.', comp.span);
1633
+ }
1634
+ }
1635
+ // ADR-242 D5: `pronouns <word>` — person-only, at most one line, and
1636
+ // the word resolves against the standard four or a `define pronouns`
1637
+ // set (never guessed; nearest-match suggestion on a miss, ruled Q-2:
1638
+ // no default is injected when the line is absent).
1639
+ let pronouns;
1640
+ if (decl.pronouns.length > 0) {
1641
+ if (!isPerson) {
1642
+ this.diagnostics.error('analysis.pronouns-person-only', `\`pronouns\` is a person line — \`${decl.name.words.join(' ')}\` is not a person.`, decl.pronouns[0].span);
1643
+ }
1644
+ for (const extra of decl.pronouns.slice(1)) {
1645
+ this.diagnostics.error('analysis.pronouns-duplicate', 'This `create` block already has a `pronouns` line.', extra.span);
1646
+ }
1647
+ const word = decl.pronouns[0].word;
1648
+ if (catalog_js_1.PRONOUN_WORDS.has(word) || this.pronounSetDecls.has(word)) {
1649
+ if (isPerson)
1650
+ pronouns = word;
1651
+ }
1652
+ else {
1653
+ const known = [...catalog_js_1.PRONOUN_WORDS, ...this.pronounSetDecls.keys()];
1654
+ this.diagnostics.error('analysis.unknown-pronouns', `\`${word}\` is not a pronoun set — the standard sets are ${[...catalog_js_1.PRONOUN_WORDS].map((w) => `\`${w}\``).join(', ')}, plus any \`define pronouns\` set${this.suggestText(word, known)}.`, decl.pronouns[0].span);
1655
+ }
1656
+ }
1657
+ // Player-block composition (Gap-2 ruling, David 2026-07-18): the
1658
+ // player composes like any entity — but only `a person` is a legal
1659
+ // kind (a no-op; the player is already an actor), and NPC behavior
1660
+ // adjectives would hand the player to the NPC service. Both gate.
1661
+ if (isPlayer) {
1662
+ for (const kind of kinds) {
1663
+ if (kind.name !== 'person') {
1664
+ this.diagnostics.error('analysis.player-kind', `The player cannot be \`a ${kind.name}\` — only \`a person\` composes on the player block (as a no-op).`, kind.span);
1665
+ }
1666
+ }
1667
+ for (const trait of traits) {
1668
+ const contributed = (0, index_js_1.manifestForAdjective)(trait.name);
1669
+ if (contributed?.manifest.name === 'npc') {
1670
+ this.diagnostics.error('analysis.player-behavior', `\`${trait.name}\` is an NPC behavior — the player is not driven by the NPC service.`, trait.span);
1671
+ }
1672
+ }
1673
+ }
1674
+ // ADR-234 D3: a door's location IS its room pair — the loader places
1675
+ // it in room1 per the platform convention; a placement line is a load
1676
+ // error (the region-placement gate is the direct precedent).
1677
+ if (kinds.some((k) => k.name === 'door') && decl.placement) {
1678
+ this.diagnostics.error('analysis.door-placement', `A door has no placement — its location IS its room pair (the loader places it in the first room of its \`through\` exit line). Remove this line.`, decl.placement.span);
1679
+ }
1680
+ // ADR-236 D1: a region's "location" IS its member list — placement
1681
+ // lines on a region block are a load error (mirror of ADR-234 D3's
1682
+ // door-placement gate).
1683
+ const isRegion = kinds.some((k) => k.name === 'region');
1684
+ if (isRegion && decl.placement) {
1685
+ this.diagnostics.error('analysis.region-placement', `A region has no location — its place IS its member list. Remove this line; membership is \`containing <rooms>\`.`, decl.placement.span);
1686
+ }
1687
+ // ADR-236 D2: `containing` is region membership — on any other block it
1688
+ // would be a silent no-op, so it is a load error, never a guess.
1689
+ if (!isRegion && decl.containing.length > 0) {
1690
+ this.diagnostics.error('analysis.region-containing-host', `\`containing\` declares region membership — \`${decl.name.words.join(' ')}\` is not a region. (Contents are placed with \`in\`/\`on\` lines on the contained entity.)`, decl.containing[0].span);
827
1691
  }
828
1692
  // Z1: `first time` prose compiles to RoomTrait.initialDescription —
829
1693
  // only rooms carry that field, so any other kind is a load error
@@ -836,9 +1700,13 @@ class Analyzer {
836
1700
  name: decl.name.words.join(' '),
837
1701
  article: decl.name.article,
838
1702
  aka: decl.aka,
1703
+ // Present only when declared and resolved (ruled Q-2: absent means
1704
+ // the platform's by-number fallback — and zero golden churn).
1705
+ ...(pronouns !== undefined ? { pronouns } : {}),
839
1706
  isPlayer,
840
1707
  kinds,
841
1708
  traits,
1709
+ startsStates,
842
1710
  placement: decl.placement
843
1711
  ? {
844
1712
  relation: decl.placement.relation,
@@ -847,7 +1715,19 @@ class Analyzer {
847
1715
  }
848
1716
  : null,
849
1717
  wears: decl.wears.map((w) => this.resolveEntityId(w) ?? '').filter((w) => w !== ''),
850
- exits: decl.exits.map((e) => ({ direction: e.direction, to: this.resolveEntityId(e.to) ?? '', span: e.span })),
1718
+ carries: decl.carries.map((c) => this.resolveEntityId(c) ?? '').filter((c) => c !== ''),
1719
+ containing: decl.containing
1720
+ .map((m) => ({ id: this.resolveEntityId(m) ?? '', span: m.span }))
1721
+ .filter((m) => m.id !== ''),
1722
+ exits: decl.exits.map((e) => ({
1723
+ direction: e.direction,
1724
+ to: this.resolveEntityId(e.to) ?? '',
1725
+ // `through the <door>` (ADR-234 D1): resolved like any entity
1726
+ // reference — an unknown name is the standard unresolved-entity
1727
+ // error; '' marks it so checkDoors skips what is already reported.
1728
+ via: e.via ? (this.resolveEntityId(e.via) ?? '') : null,
1729
+ span: e.span,
1730
+ })),
851
1731
  blockedExits: decl.blockedExits.map((b) => {
852
1732
  this.requirePhrase(b.phraseKey, b.span);
853
1733
  return {
@@ -857,6 +1737,27 @@ class Analyzer {
857
1737
  span: b.span,
858
1738
  };
859
1739
  }),
1740
+ deadlyExits: decl.deadlyExits.map((d) => {
1741
+ this.requirePhrase(d.phraseKey, d.span);
1742
+ // Compile gate (platform-issue-sweep Phase 8 #15d): the conditional
1743
+ // form is post-scope (mirror: role-bound trait clauses). It used to
1744
+ // fail only at LOAD (loader.ts throw — kept there as the defensive
1745
+ // backstop), which the harness's expect-fail-manifest convention
1746
+ // cannot pin; failing here makes it a compile diagnostic.
1747
+ if (d.condition !== null) {
1748
+ this.diagnostics.error('analysis.deadly-while-unsupported', '`is deadly while <condition>` is not wired yet — the conditional deadly exit is post-scope. Use an unconditional `is deadly:` or an `on going` clause with `kill the player when <condition>`.', d.span);
1749
+ }
1750
+ return {
1751
+ direction: d.direction,
1752
+ phraseKey: d.phraseKey,
1753
+ condition: d.condition ? this.resolveCondition(d.condition, entityScope(sym ?? null)) : null,
1754
+ span: d.span,
1755
+ };
1756
+ }),
1757
+ deadly: decl.deadly
1758
+ ? (this.requirePhrase(decl.deadly.phraseKey, decl.deadly.span),
1759
+ { phraseKey: decl.deadly.phraseKey, span: decl.deadly.span })
1760
+ : null,
860
1761
  // Merged set: own `states:` plus every composed trait's declared
861
1762
  // states (ratchet D8) — the loader initializes from states[0].
862
1763
  states: sym ? sym.states : decl.states.map((s) => s.name),
@@ -864,6 +1765,8 @@ class Analyzer {
864
1765
  descriptionKey: decl.description ? `${id}.description` : null,
865
1766
  initialDescriptionKey: decl.initialDescription ? `${id}.initial-description` : null,
866
1767
  onClauses: this.checkDuplicateClauses(decl.onClauses, decl.name.words.join(' ').toLowerCase()).map((c) => this.buildOnClause(c, entityScope(sym ?? null))),
1768
+ // Filled by applyTopics after every entity is built (ADR-239).
1769
+ topics: [],
867
1770
  span: decl.span,
868
1771
  };
869
1772
  }
@@ -984,8 +1887,26 @@ class Analyzer {
984
1887
  span: stmt.span,
985
1888
  };
986
1889
  }
987
- case 'emit':
988
- return { kind: 'emit', event: stmt.event.join(' '), stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
1890
+ case 'emit': {
1891
+ const payload = stmt.payload.map((f) => this.resolveEmitField(f, scope));
1892
+ // ADR-253 D1: record the event's top-level payload field names so a
1893
+ // channel `return`ing an unknown field is caught (checkChannelReturns).
1894
+ const eventId = stmt.event.join(' ');
1895
+ let fields = this.emitFields.get(eventId);
1896
+ if (!fields) {
1897
+ fields = new Set();
1898
+ this.emitFields.set(eventId, fields);
1899
+ }
1900
+ for (const f of stmt.payload)
1901
+ fields.add(f.key.join(' '));
1902
+ return {
1903
+ kind: 'emit',
1904
+ event: eventId,
1905
+ ...(payload.length > 0 ? { payload } : {}),
1906
+ stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
1907
+ span: stmt.span,
1908
+ };
1909
+ }
989
1910
  case 'set':
990
1911
  return {
991
1912
  kind: 'set',
@@ -1058,6 +1979,7 @@ class Analyzer {
1058
1979
  }
1059
1980
  case 'win':
1060
1981
  case 'lose':
1982
+ case 'kill':
1061
1983
  if (stmt.phraseKey)
1062
1984
  this.requirePhrase(stmt.phraseKey, stmt.span, scope.owner);
1063
1985
  return { kind: stmt.kind, phraseKey: stmt.phraseKey, stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
@@ -1083,6 +2005,8 @@ class Analyzer {
1083
2005
  span: stmt.span,
1084
2006
  };
1085
2007
  }
2008
+ case 'media':
2009
+ return this.lowerMediaStatement(stmt, scope);
1086
2010
  case 'select-on': {
1087
2011
  const subject = this.resolveValue(stmt.subject, scope);
1088
2012
  const stateOwner = this.stateOwnerOf(subject, scope);
@@ -1189,6 +2113,230 @@ class Analyzer {
1189
2113
  return this.resolveMatch(expr.span, scope);
1190
2114
  }
1191
2115
  }
2116
+ /**
2117
+ * Lower one media sugar statement (ADR-216) onto a payloaded `media-*`
2118
+ * emit — pure compile-time sugar, no runtime surface of its own. The IR
2119
+ * event id is the dotless Chord form (ADR-254/256); `@sharpee/story-loader`
2120
+ * translates it to the platform's dotted `media.*` id at the emit seam.
2121
+ * Asset references are typo-checked with a nearest-match suggestion; kind
2122
+ * mismatches gate (`play ambient` plays SOUND assets — an ambient loop
2123
+ * is a sound file).
2124
+ */
2125
+ lowerMediaStatement(stmt, scope) {
2126
+ const stmtWhen = this.resolveStmtWhen(stmt.stmtWhen, scope);
2127
+ const fields = [];
2128
+ let event = '';
2129
+ const requireAsset = (expected) => {
2130
+ if (!stmt.asset)
2131
+ return; // the parser already reported
2132
+ const found = this.assets.get(stmt.asset);
2133
+ if (!found) {
2134
+ this.diagnostics.error('analysis.unknown-asset', `\`${stmt.asset}\` names no declared asset${this.suggestText(stmt.asset, [...this.assets.keys()])}. Declare it: \`define ${expected} ${stmt.asset} from "<file>"\`.`, stmt.span);
2135
+ return;
2136
+ }
2137
+ if (found.kind !== expected) {
2138
+ this.diagnostics.error('analysis.asset-kind', `\`${stmt.asset}\` is a ${found.kind} asset — this statement needs a ${expected} asset.`, stmt.span);
2139
+ return;
2140
+ }
2141
+ fields.push({ key: 'src', value: { kind: 'literal', value: found.path, valueType: 'string' } });
2142
+ };
2143
+ switch (stmt.form) {
2144
+ case 'play-sound':
2145
+ event = 'media-sound-play';
2146
+ requireAsset('sound');
2147
+ break;
2148
+ case 'play-music':
2149
+ event = 'media-music-play';
2150
+ requireAsset('music');
2151
+ if (stmt.looping)
2152
+ fields.push({ key: 'loop', value: { kind: 'value', value: { kind: 'symbol', name: 'true' } } });
2153
+ break;
2154
+ case 'stop-music':
2155
+ event = 'media-music-stop';
2156
+ break;
2157
+ case 'play-ambient':
2158
+ event = 'media-ambient-play';
2159
+ requireAsset('sound');
2160
+ this.stampAmbientChannel(stmt, fields);
2161
+ break;
2162
+ case 'stop-ambient':
2163
+ event = 'media-ambient-stop';
2164
+ this.stampAmbientChannel(stmt, fields);
2165
+ break;
2166
+ case 'show-image':
2167
+ event = 'media-image-show';
2168
+ requireAsset('image');
2169
+ if (stmt.layer) {
2170
+ // ADR-241 D3: layers beyond the platform's pre-registered three
2171
+ // must be declared (`define layer <word>`) — never-guess.
2172
+ if (!IMPLIED_IMAGE_LAYERS.has(stmt.layer) && !this.familyChannels.layer.has(stmt.layer)) {
2173
+ this.diagnostics.error('analysis.unknown-channel', `\`${stmt.layer}\` names no declared image layer${this.suggestText(stmt.layer, [...IMPLIED_IMAGE_LAYERS, ...this.familyChannels.layer.keys()])}. Declare it: \`define layer ${stmt.layer}\`.`, stmt.span);
2174
+ }
2175
+ fields.push({ key: 'layer', value: { kind: 'literal', value: stmt.layer, valueType: 'string' } });
2176
+ }
2177
+ break;
2178
+ case 'hide-image':
2179
+ event = 'media-image-hide';
2180
+ break;
2181
+ case 'transition':
2182
+ event = 'media-transition';
2183
+ fields.push({ key: 'kind', value: { kind: 'literal', value: stmt.transitionKind ?? '', valueType: 'string' } });
2184
+ break;
2185
+ case 'clear':
2186
+ event = 'media-clear';
2187
+ break;
2188
+ }
2189
+ return { kind: 'emit', event, ...(fields.length > 0 ? { payload: fields } : {}), stmtWhen, span: stmt.span };
2190
+ }
2191
+ /**
2192
+ * ADR-241 D3: resolve an ambient statement's channel word (the default
2193
+ * bed is `main` — Q-1), gate undeclared words (never-guess — Q-3), and
2194
+ * stamp `channel` onto the payload — the field stdlib's ambient
2195
+ * channels filter on. A bare use of the implied `main` bed records its
2196
+ * first use-site so the bed joins the channel manifest (D4).
2197
+ */
2198
+ stampAmbientChannel(stmt, fields) {
2199
+ const word = stmt.channel ?? 'main';
2200
+ if (word === 'main') {
2201
+ if (!this.familyChannels.ambient.has('main'))
2202
+ this.impliedMainBedSpan ??= stmt.span;
2203
+ }
2204
+ else if (!this.familyChannels.ambient.has(word)) {
2205
+ this.diagnostics.error('analysis.unknown-channel', `\`${word}\` names no declared ambient bed${this.suggestText(word, ['main', ...this.familyChannels.ambient.keys()])}. Declare it: \`define ambient ${word}\`.`, stmt.span);
2206
+ }
2207
+ fields.push({ key: 'channel', value: { kind: 'literal', value: word, valueType: 'string' } });
2208
+ }
2209
+ /** Channel names seen (duplicate gate). */
2210
+ channelNames = new Set();
2211
+ /** Declared family channels (ADR-241 D2) by family: word → first declaration span. */
2212
+ familyChannels = {
2213
+ ambient: new Map(),
2214
+ layer: new Map(),
2215
+ };
2216
+ /** First use-site of the implied `main` ambient bed (ADR-241 D3), if any. */
2217
+ impliedMainBedSpan = null;
2218
+ /**
2219
+ * Build one `define channel` (ADR-216; spelling A): mode/from/take are
2220
+ * required, `gated by` must name a client capability flag (Chord
2221
+ * spelling, lowered to the platform's camelCase key). Re-declaring a
2222
+ * STANDARD channel id is legal platform behavior (story override);
2223
+ * duplicating a story channel is not.
2224
+ */
2225
+ buildChannel(decl) {
2226
+ if (this.channelNames.has(decl.name)) {
2227
+ this.diagnostics.error('analysis.duplicate-channel', `A channel named \`${decl.name}\` already exists.`, decl.span);
2228
+ }
2229
+ this.channelNames.add(decl.name);
2230
+ if (decl.mode === null || !['replace', 'append', 'event'].includes(decl.mode)) {
2231
+ this.diagnostics.error('analysis.channel-mode', `Channel \`${decl.name}\` needs \`mode replace\`, \`mode append\`, or \`mode event\`${decl.mode ? ` — got \`${decl.mode}\`` : ''}.`, decl.span);
2232
+ }
2233
+ if (decl.returns === null || decl.fromEvent === null) {
2234
+ this.diagnostics.error('analysis.channel-return', `Channel \`${decl.name}\` needs a \`return <field | "text" | phrase <key>> from <event>\` line.`, decl.span);
2235
+ }
2236
+ let gatedBy = null;
2237
+ if (decl.gatedBy !== null) {
2238
+ if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(decl.gatedBy)) {
2239
+ this.diagnostics.error('analysis.unknown-capability', `\`${decl.gatedBy}\` is not a client capability flag${this.suggestText(decl.gatedBy, [...catalog_js_1.CLIENT_CAPABILITY_FLAGS])}.`, decl.span);
2240
+ }
2241
+ else {
2242
+ gatedBy = (0, catalog_js_1.capabilityKeyOf)(decl.gatedBy);
2243
+ }
2244
+ }
2245
+ return {
2246
+ name: decl.name,
2247
+ family: 'data',
2248
+ mode: (decl.mode ?? 'event'),
2249
+ gatedBy,
2250
+ fromEvent: decl.fromEvent ?? '',
2251
+ returns: decl.returns ?? { kind: 'field', field: '' },
2252
+ span: decl.span,
2253
+ };
2254
+ }
2255
+ /**
2256
+ * ADR-253 D1 field check: a channel returning a `field` (or a `text` template
2257
+ * with `(slot)` names) references event payload fields; error when the named
2258
+ * event emits a payload that lacks one. Runs as a post-pass so every `emit`
2259
+ * is collected first, regardless of declaration order. An event with no
2260
+ * collected payload (platform events, or one never emitted here) is skipped —
2261
+ * its field set is unknown, not empty. `phrase` returns own their slots
2262
+ * (the phrase system), so they are not cross-checked here.
2263
+ */
2264
+ checkChannelReturns(channels) {
2265
+ for (const ch of channels) {
2266
+ if (ch.family !== 'data')
2267
+ continue;
2268
+ const emitted = this.emitFields.get(ch.fromEvent);
2269
+ if (!emitted)
2270
+ continue; // unknown field set — cannot check
2271
+ const required = ch.returns.kind === 'field'
2272
+ ? [ch.returns.field]
2273
+ : ch.returns.kind === 'text'
2274
+ ? [...ch.returns.text.matchAll(/\(\s*([^)]+?)\s*\)/g)].map((m) => m[1])
2275
+ : [];
2276
+ for (const field of required) {
2277
+ if (!emitted.has(field)) {
2278
+ this.diagnostics.error('analysis.channel-return-field', `Channel \`${ch.name}\` returns \`${field}\`, but the \`${ch.fromEvent}\` event carries no such field (it emits: ${[...emitted].join(', ') || '(none)'}).`, ch.span);
2279
+ }
2280
+ }
2281
+ }
2282
+ }
2283
+ /**
2284
+ * `define pronouns` (ADR-242 D7): exactly the five case rows the
2285
+ * assembler's pronoun table keys — a missing or duplicate row is an
2286
+ * error (order is free; named rows, ruled Q-1). Forms pass through as
2287
+ * data; the language provider owns rendering them.
2288
+ */
2289
+ buildPronounSet(decl) {
2290
+ const byCase = new Map();
2291
+ for (const row of decl.rows) {
2292
+ if (byCase.has(row.case)) {
2293
+ this.diagnostics.error('analysis.pronoun-set-duplicate-row', `\`define pronouns ${decl.name}\` already has a \`${row.case}\` row.`, row.span);
2294
+ continue;
2295
+ }
2296
+ byCase.set(row.case, row.form);
2297
+ }
2298
+ for (const c of catalog_js_1.PRONOUN_CASES) {
2299
+ if (!byCase.has(c)) {
2300
+ this.diagnostics.error('analysis.pronoun-set-rows', `\`define pronouns ${decl.name}\` is missing its \`${c}\` row — all five case rows are required.`, decl.span);
2301
+ }
2302
+ }
2303
+ return {
2304
+ name: decl.name,
2305
+ forms: {
2306
+ subject: byCase.get('subject') ?? '',
2307
+ object: byCase.get('object') ?? '',
2308
+ possessive: byCase.get('possessive') ?? '',
2309
+ possessivePronoun: byCase.get('possessive-pronoun') ?? '',
2310
+ reflexive: byCase.get('reflexive') ?? '',
2311
+ },
2312
+ span: decl.span,
2313
+ };
2314
+ }
2315
+ /** Resolve one emit-payload field (ADR-216) — key words join with a space, passed verbatim. */
2316
+ resolveEmitField(field, scope) {
2317
+ return { key: field.key.join(' '), value: this.resolveEmitValue(field.value, scope) };
2318
+ }
2319
+ /** Resolve one emit-payload value (ADR-216) — recursive over arrays/objects. */
2320
+ resolveEmitValue(value, scope) {
2321
+ switch (value.kind) {
2322
+ case 'literal':
2323
+ return { kind: 'literal', value: value.value, valueType: value.literalKind };
2324
+ case 'expr':
2325
+ return { kind: 'value', value: this.resolveValue(value.expr, scope) };
2326
+ case 'array':
2327
+ return { kind: 'array', items: value.items.map((i) => this.resolveEmitValue(i, scope)) };
2328
+ case 'object':
2329
+ return { kind: 'object', fields: value.fields.map((f) => this.resolveEmitField(f, scope)) };
2330
+ }
2331
+ }
2332
+ /**
2333
+ * `it` in a story-owned clause (ADR-236 D7): the story is the owner and
2334
+ * has no entity referent — the unbound-referent case no other clause
2335
+ * home can produce. A load error, never a silent undefined.
2336
+ */
2337
+ reportStoryClauseIt(span) {
2338
+ this.diagnostics.error('analysis.story-clause-it', '`it` is not bound in a story-owned clause — the story has no entity referent. Name the entity, or use `the player`.', span);
2339
+ }
1192
2340
  /**
1193
2341
  * `the match` — the `each`-block binder (ratchet E3). Legal only inside
1194
2342
  * an `each` body, at any nesting depth (the runtime binds innermost).
@@ -1225,8 +2373,11 @@ class Analyzer {
1225
2373
  }
1226
2374
  resolveRefValue(ref, scope) {
1227
2375
  const words = ref.words.map((w) => w.toLowerCase());
1228
- if (words.length === 1 && words[0] === 'it')
2376
+ if (words.length === 1 && words[0] === 'it') {
2377
+ if (scope.storyOwned)
2378
+ this.reportStoryClauseIt(ref.span);
1229
2379
  return { kind: 'it' };
2380
+ }
1230
2381
  // `the match` in NameRef positions (`change`/`move` targets, predicate
1231
2382
  // things) resolves to the binder exactly as `it` does — before entity
1232
2383
  // lookup; the name itself is reserved at declaration (E3/P3).
@@ -1240,6 +2391,8 @@ class Analyzer {
1240
2391
  }
1241
2392
  // `its <field>` in name position (`the actor has its food`).
1242
2393
  if (words.length > 1 && words[0] === 'its') {
2394
+ if (scope.storyOwned)
2395
+ this.reportStoryClauseIt(ref.span);
1243
2396
  return { kind: 'field', base: { kind: 'it' }, field: words.slice(1).join(' ') };
1244
2397
  }
1245
2398
  const scoped = this.resolveScopedWords(ref.words, scope);
@@ -1305,6 +2458,14 @@ class Analyzer {
1305
2458
  return { kind: 'not', operand: this.resolveCondition(cond.operand, scope) };
1306
2459
  case 'chance':
1307
2460
  return { kind: 'chance', n: cond.n };
2461
+ case 'client-has': {
2462
+ // ADR-216: capability words are the closed platform flag set —
2463
+ // validated here, lowered to the camelCase platform key.
2464
+ if (!catalog_js_1.CLIENT_CAPABILITY_FLAGS.has(cond.capability)) {
2465
+ this.diagnostics.error('analysis.unknown-capability', `\`${cond.capability}\` is not a client capability flag${this.suggestText(cond.capability, [...catalog_js_1.CLIENT_CAPABILITY_FLAGS])}.`, cond.span);
2466
+ }
2467
+ return { kind: 'client-has', capability: (0, catalog_js_1.capabilityKeyOf)(cond.capability) };
2468
+ }
1308
2469
  case 'condition-ref': {
1309
2470
  if (this.conditionNames.has(cond.name)) {
1310
2471
  // Never-guess gate (grammar log 2026-07-11): an OPEN condition is a
@@ -1447,15 +2608,15 @@ class Analyzer {
1447
2608
  const validStates = subjectEntity?.states ?? (subject.kind === 'it' ? scope.ownStates ?? [] : []);
1448
2609
  if (validStates.includes(word))
1449
2610
  return { kind: 'symbol', name: word };
1450
- if (catalog_1.TRAIT_ADJECTIVES.has(word))
2611
+ if (catalog_js_1.TRAIT_ADJECTIVES.has(word))
1451
2612
  return { kind: 'symbol', name: word };
1452
2613
  // State adjectives (ratchet D1): read live from world trait state.
1453
- if (catalog_1.STATE_ADJECTIVES.has(word))
2614
+ if (catalog_js_1.STATE_ADJECTIVES.has(word))
1454
2615
  return { kind: 'symbol', name: word };
1455
2616
  const exactEntity = this.entities.find((e) => e.nameLower === word.toLowerCase() || e.aka.includes(word.toLowerCase()));
1456
2617
  if (exactEntity)
1457
2618
  return { kind: 'entity', id: exactEntity.id };
1458
- const valid = [...validStates, ...catalog_1.TRAIT_ADJECTIVES, ...catalog_1.STATE_ADJECTIVES];
2619
+ const valid = [...validStates, ...catalog_js_1.TRAIT_ADJECTIVES, ...catalog_js_1.STATE_ADJECTIVES];
1459
2620
  this.diagnostics.error('analysis.unknown-value', `\`${word}\` is not a state${subjectEntity ? ` of ${subjectEntity.nameLower}` : ''} or a known trait${this.suggestText(word, valid)}.`, span);
1460
2621
  return { kind: 'symbol', name: word };
1461
2622
  }
@@ -1480,6 +2641,15 @@ class Analyzer {
1480
2641
  return;
1481
2642
  if (owner && table?.has(`${owner.id}.${key}`))
1482
2643
  return;
2644
+ // ADR-250 D4.6: book coverage counts as declaration. Covered only by
2645
+ // predicated books → warn once (off-book it renders nothing).
2646
+ if (this.bookKeys.has(key)) {
2647
+ if (!this.bookKeys.get(key) && !this.partialCoverageWarned.has(key)) {
2648
+ this.partialCoverageWarned.add(key);
2649
+ this.diagnostics.warning('analysis.phrasebook-partial-coverage', `\`${key}\` is only defined in conditional phrasebooks — when no book is active it renders nothing.`, span);
2650
+ }
2651
+ return;
2652
+ }
1483
2653
  const known = table ? [...table.keys()] : [];
1484
2654
  this.diagnostics.error('analysis.missing-phrase', `Phrase \`${key}\` is not declared in ${DEFAULT_LOCALE}${this.suggestText(key, known)}.`, span);
1485
2655
  }
@@ -1491,26 +2661,40 @@ class Analyzer {
1491
2661
  checkMarkers() {
1492
2662
  for (const [, table] of this.phrases) {
1493
2663
  for (const [key, phrase] of table) {
1494
- for (const variant of phrase.variants) {
1495
- // A variant carrying formatter-chain forms ({You}, {the item},
1496
- // {verb:…}) is a TEMPLATE — its bare lowercase markers are chain
1497
- // verbs ({open}), not producer references (Phase B: §3.2 trait
1498
- // phrases). Full chain validation lands with the AC-9 contract.
1499
- const isTemplate = variant.markers.some((m) => /[A-Z]/.test(m[0] ?? '') || m.includes(' ') || m.includes(':'));
1500
- if (isTemplate)
1501
- continue;
1502
- for (const marker of variant.markers) {
1503
- if (!/^[a-z][a-z0-9-]*$/.test(marker))
1504
- continue;
1505
- if (marker === 'br')
1506
- continue; // built-in line break (grammar log 2026-07-10)
1507
- if (this.hatchNames.has(marker))
1508
- continue;
1509
- if (this.phrases.get(DEFAULT_LOCALE)?.has(marker))
1510
- continue;
1511
- this.diagnostics.error('analysis.unbound-marker', `\`{${marker}}\` in phrase \`${key}\` is not a declared text producer or phrase${this.suggestText(marker, [...this.hatchNames])}.`, phrase.span);
1512
- }
1513
- }
2664
+ this.checkPhraseMarkers(key, phrase);
2665
+ }
2666
+ }
2667
+ // Phrasebook entries carry the same marker contract (ADR-250 D1
2668
+ // entries are ordinary phrase definitions).
2669
+ for (const book of this.phrasebookDecls) {
2670
+ if (!book.entries)
2671
+ continue;
2672
+ for (const [key, phrase] of Object.entries(book.entries)) {
2673
+ this.checkPhraseMarkers(`${book.name}.${key}`, phrase);
2674
+ }
2675
+ }
2676
+ }
2677
+ checkPhraseMarkers(label, phrase) {
2678
+ for (const variant of phrase.variants) {
2679
+ // A variant carrying formatter-chain forms ({You}, {the item},
2680
+ // {verb:…}) is a TEMPLATE — its bare lowercase markers are chain
2681
+ // verbs ({open}), not producer references (Phase B: §3.2 trait
2682
+ // phrases). Full chain validation lands with the AC-9 contract.
2683
+ const isTemplate = variant.markers.some((m) => /[A-Z]/.test(m[0] ?? '') || m.includes(' ') || m.includes(':'));
2684
+ if (isTemplate)
2685
+ continue;
2686
+ for (const marker of variant.markers) {
2687
+ if (!/^[a-z][a-z0-9-]*$/.test(marker))
2688
+ continue;
2689
+ if (marker === 'br')
2690
+ continue; // built-in line break (grammar log 2026-07-10)
2691
+ if (this.hatchNames.has(marker))
2692
+ continue;
2693
+ if (this.phrases.get(DEFAULT_LOCALE)?.has(marker))
2694
+ continue;
2695
+ if (this.bookKeys.has(marker))
2696
+ continue; // book coverage counts (ADR-250 D4.6)
2697
+ this.diagnostics.error('analysis.unbound-marker', `\`{${marker}}\` in phrase \`${label}\` is not a declared text producer or phrase${this.suggestText(marker, [...this.hatchNames])}.`, phrase.span);
1514
2698
  }
1515
2699
  }
1516
2700
  }