@sharpee/chord 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/analyzer.js ADDED
@@ -0,0 +1,1656 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyze = analyze;
4
+ const catalog_1 = require("./catalog");
5
+ const ir_1 = require("./ir");
6
+ /** Phase A stories register text in this locale (design.md §2.6). */
7
+ const DEFAULT_LOCALE = 'en-US';
8
+ const PLAYER_WORDS = new Set(['player', 'you', 'yourself']);
9
+ /** Reserved-name gate text (David, 2026-07-12 — each package P3). */
10
+ const RESERVED_MATCH_MESSAGE = '`match` is reserved for the `each`-block binder `the match` (ratchet E3) — pick another name.';
11
+ /**
12
+ * Z3/Z3b reserved channel keys — entity-owned, platform-PULLED phrase
13
+ * surfaces (occupant lifecycle family + examine detail). Bare declarations
14
+ * and `phrase`-statement pushes are load errors; the entity `phrase <key>:`
15
+ * block is the one authoring surface.
16
+ */
17
+ const RESERVED_CHANNEL_KEYS = new Set(['present', 'entered', 'exited', 'disappeared', 'detail']);
18
+ /** Ring 1 of the boolean-state gate (D9): literal booleans as state names. */
19
+ const BOOLEAN_STATE_WORDS = new Set(['true', 'false', 'yes', 'no']);
20
+ /**
21
+ * Negation prefixes/suffix for ring 3 of the boolean-state gate (D9):
22
+ * `not-`/`un-`/`non-` (hyphenated or fused), `no-` (hyphenated only — bare
23
+ * `no` false-positives on words like `noon`), shared-stem prefixes
24
+ * (`active`/`inactive`), and the `-less` suffix.
25
+ */
26
+ const NEGATION_PREFIXES = ['not-', 'not', 'un-', 'un', 'non-', 'non', 'no-', 'in', 'im', 'dis'];
27
+ /** True when `candidate` is a negation-shaped form of `base` (D9 ring 3). */
28
+ function isNegationOf(candidate, base) {
29
+ if (base.length < 2)
30
+ return false;
31
+ for (const prefix of NEGATION_PREFIXES) {
32
+ if (candidate === prefix + base)
33
+ return true;
34
+ }
35
+ return candidate === `${base}-less` || candidate === `${base}less`;
36
+ }
37
+ /**
38
+ * Span-free structural fingerprint of a condition, for the duplicate-clause
39
+ * gate's per-condition event-clause key. Same shape → same string.
40
+ */
41
+ function conditionFingerprint(cond) {
42
+ const value = (v) => {
43
+ switch (v.kind) {
44
+ case 'literal':
45
+ return `lit:${v.value}`;
46
+ case 'ref':
47
+ return `ref:${v.ref.words.join(' ').toLowerCase()}`;
48
+ case 'bare':
49
+ return `bare:${v.words.join(' ').toLowerCase()}`;
50
+ case 'possessive':
51
+ return `poss:${value(v.base)}.${v.field.join(' ').toLowerCase()}`;
52
+ case 'match':
53
+ return 'match';
54
+ }
55
+ };
56
+ switch (cond.kind) {
57
+ case 'or':
58
+ case 'and':
59
+ return `${cond.kind}(${cond.operands.map(conditionFingerprint).join(',')})`;
60
+ case 'not':
61
+ return `not(${conditionFingerprint(cond.operand)})`;
62
+ case 'chance':
63
+ return `chance:${cond.n}`;
64
+ case 'condition-ref':
65
+ return `cond:${cond.name}`;
66
+ case 'any-of':
67
+ case 'none-of':
68
+ return `${cond.kind}:${cond.condition}`;
69
+ case 'predicate': {
70
+ const p = cond.predicate;
71
+ switch (p.kind) {
72
+ case 'is':
73
+ return `is${p.negated ? '!' : ''}(${value(cond.subject)},${value(p.value)})`;
74
+ case 'is-a':
75
+ return `is-a${p.negated ? '!' : ''}(${value(cond.subject)},${p.classifier.join(' ').toLowerCase()})`;
76
+ case 'is-in':
77
+ return `is-in${p.negated ? '!' : ''}(${value(cond.subject)},${p.place.words.join(' ').toLowerCase()})`;
78
+ case 'is-here':
79
+ return `is-here${p.negated ? '!' : ''}(${value(cond.subject)})`;
80
+ case 'has':
81
+ case 'holds':
82
+ case 'wears':
83
+ return `${p.kind}(${value(cond.subject)},${p.thing.words.join(' ').toLowerCase()})`;
84
+ case 'can':
85
+ return `can-${p.ability}(${value(cond.subject)},${p.thing.words.join(' ').toLowerCase()})`;
86
+ case 'is-any':
87
+ return `is-any(${value(cond.subject)},${p.condition})`;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Curated role vocabulary for standard-semantics actions (design.md §2.2:
94
+ * roles are declared by the action). Grows with the event-selector map.
95
+ */
96
+ const STANDARD_ACTION_ROLES = {
97
+ taking: ['taker', 'item'],
98
+ };
99
+ const TOP_SCOPE = { owner: null, fields: null, slots: null, ownStates: null, scoreOwner: null, inEach: false };
100
+ function entityScope(owner) {
101
+ return { owner, fields: null, slots: null, ownStates: null, scoreOwner: owner?.id ?? null, inEach: false };
102
+ }
103
+ /** True when the scope binds `it` (entity clause or trait clause). */
104
+ function scopeHasIt(scope) {
105
+ return scope.owner !== null || scope.fields !== null;
106
+ }
107
+ // The when-header verb-derivation table (petting → pets) died with floating
108
+ // `when` rules (ownership package, ratchet 2026-07-11) — event clauses use
109
+ // the same gerund register as every other on/after clause.
110
+ /** True when a name reference is the word `it`. */
111
+ function nameIsIt(ref) {
112
+ return ref.words.length === 1 && ref.words[0].toLowerCase() === 'it';
113
+ }
114
+ /**
115
+ * OPEN/CLOSED classification (grammar log 2026-07-11): a condition that
116
+ * references `it`/`its` is a selection criterion over entities.
117
+ */
118
+ function conditionReferencesIt(cond) {
119
+ const visitValue = (v) => {
120
+ switch (v.kind) {
121
+ case 'ref':
122
+ return nameIsIt(v.ref);
123
+ case 'bare':
124
+ return v.words.length === 1 && ['it', 'its'].includes(v.words[0].toLowerCase());
125
+ case 'possessive':
126
+ return visitValue(v.base);
127
+ case 'literal':
128
+ return false;
129
+ case 'match':
130
+ // The `each`-block binder is its own binding — not `it` (E3).
131
+ return false;
132
+ }
133
+ };
134
+ switch (cond.kind) {
135
+ case 'or':
136
+ case 'and':
137
+ return cond.operands.some(conditionReferencesIt);
138
+ case 'not':
139
+ return conditionReferencesIt(cond.operand);
140
+ case 'chance':
141
+ case 'condition-ref':
142
+ return false;
143
+ case 'any-of':
144
+ case 'none-of':
145
+ // Quantifiers test the world, not the clause owner — no `it` here.
146
+ return false;
147
+ case 'predicate': {
148
+ if (visitValue(cond.subject))
149
+ return true;
150
+ const p = cond.predicate;
151
+ switch (p.kind) {
152
+ case 'is':
153
+ return visitValue(p.value);
154
+ case 'is-in':
155
+ return nameIsIt(p.place);
156
+ case 'has':
157
+ case 'holds':
158
+ case 'wears':
159
+ case 'can':
160
+ return nameIsIt(p.thing);
161
+ case 'is-a':
162
+ // `is here` has no object node — the subject was already visited above.
163
+ case 'is-here':
164
+ // The membership form's condition selects its own entities — the
165
+ // subject was already visited above (E1/P3).
166
+ case 'is-any':
167
+ return false;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Analyze a parsed story and build its IR.
174
+ * @param ast parser output (may be partial when parse errors occurred)
175
+ * @param diagnostics receives load-time gate errors
176
+ * @returns the IR — meaningful only when diagnostics has no errors (atomic load)
177
+ */
178
+ function analyze(ast, diagnostics) {
179
+ return new Analyzer(ast, diagnostics).run();
180
+ }
181
+ class Analyzer {
182
+ ast;
183
+ diagnostics;
184
+ entities = [];
185
+ byId = new Map();
186
+ conditionNames = new Set();
187
+ hatchNames = new Set();
188
+ /** locale → key → IRPhrase */
189
+ phrases = new Map();
190
+ // Phase B namespaces:
191
+ traitNames = new Set();
192
+ /** action name → declared grammar-slot names. */
193
+ actionSlots = new Map();
194
+ /** Owner-qualified score id (`pygmy-goats.fed`, story-level bare) → worth. */
195
+ scoreNames = new Map();
196
+ /** Owner-qualified score declarations, for ir.scores emission. */
197
+ scoreDecls = [];
198
+ /** condition name → OPEN (references `it`/`its`; usable as a selection). */
199
+ openConditions = new Map();
200
+ // Ownership package (Phase C):
201
+ /** Story-declared phases (ratchet D2); bare names are condition refs. */
202
+ storyStates = [];
203
+ /** trait name → trait-declared states (ratchet D8). */
204
+ traitStates = new Map();
205
+ /** trait name → its `states:` line permits back-transitions (D4). */
206
+ traitReversible = new Map();
207
+ /**
208
+ * trait name → states visible on `it` inside the trait's clauses: its own
209
+ * set plus every composer's full merged set (D8: `restless` reads
210
+ * feedable's `hungry` — resolution is across the composer's trait set).
211
+ */
212
+ traitVisibleStates = new Map();
213
+ constructor(ast, diagnostics) {
214
+ this.ast = ast;
215
+ this.diagnostics = diagnostics;
216
+ }
217
+ run() {
218
+ this.collect();
219
+ const ir = {
220
+ format: ir_1.IR_FORMAT,
221
+ meta: {
222
+ title: this.ast.header?.title ?? '',
223
+ author: this.ast.header?.author ?? '',
224
+ fields: this.ast.header?.fields ?? {},
225
+ },
226
+ story: {
227
+ states: this.storyStates,
228
+ reversible: this.ast.header?.statesReversible ?? false,
229
+ },
230
+ entities: [],
231
+ conditions: [],
232
+ phrases: { defaultLocale: DEFAULT_LOCALE, locales: {} },
233
+ verbs: [],
234
+ hatches: [],
235
+ traits: [],
236
+ actions: [],
237
+ scores: this.scoreDecls,
238
+ sequences: [],
239
+ hasHatches: false,
240
+ };
241
+ for (const decl of this.ast.declarations) {
242
+ switch (decl.kind) {
243
+ case 'create':
244
+ ir.entities.push(this.buildEntity(decl));
245
+ break;
246
+ case 'define-condition':
247
+ ir.conditions.push({
248
+ name: decl.name,
249
+ open: this.openConditions.get(decl.name) ?? false,
250
+ condition: this.resolveCondition(decl.condition, TOP_SCOPE),
251
+ span: decl.span,
252
+ });
253
+ break;
254
+ case 'define-verb':
255
+ ir.verbs.push({
256
+ verbs: decl.verbs,
257
+ pattern: decl.pattern.map((p) => ({ kind: p.kind, word: p.word })),
258
+ span: decl.span,
259
+ });
260
+ break;
261
+ case 'define-text':
262
+ ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: 'text', span: decl.span });
263
+ break;
264
+ case 'define-hatch':
265
+ ir.hatches.push({ name: decl.name, modulePath: decl.modulePath, hatchKind: decl.hatchKind, span: decl.span });
266
+ break;
267
+ case 'define-trait':
268
+ ir.traits.push(this.buildTrait(decl));
269
+ break;
270
+ case 'define-action':
271
+ ir.actions.push(this.buildAction(decl));
272
+ break;
273
+ case 'define-sequence':
274
+ ir.sequences.push({
275
+ name: decl.name.join(' '),
276
+ // Decision 10: sequences are story-owned — narration broadcasts.
277
+ narration: 'broadcast',
278
+ steps: decl.steps.map((step) => ({
279
+ timing: step.timing,
280
+ turns: step.turns,
281
+ anchor: this.resolveStepAnchor(step),
282
+ body: step.body.map((s) => this.resolveStatement(s, TOP_SCOPE)),
283
+ span: step.span,
284
+ })),
285
+ span: decl.span,
286
+ });
287
+ break;
288
+ case 'define-phrase':
289
+ // Collected in pass 1; the Z2 header gate resolves here, in pass 2,
290
+ // where every entity symbol already exists (`while the zookeeper is
291
+ // here` may reference an entity declared after the phrase).
292
+ if (decl.condition) {
293
+ const entry = this.phrases.get(DEFAULT_LOCALE)?.get(decl.key);
294
+ if (entry)
295
+ entry.condition = this.resolveCondition(decl.condition, TOP_SCOPE);
296
+ }
297
+ break;
298
+ case 'define-phrases':
299
+ break; // collected in pass 1
300
+ }
301
+ }
302
+ for (const [locale, table] of this.phrases) {
303
+ ir.phrases.locales[locale] = Object.fromEntries(table);
304
+ }
305
+ ir.hasHatches = ir.hatches.length > 0;
306
+ this.checkMarkers();
307
+ this.checkDescriptionMarkers();
308
+ return ir;
309
+ }
310
+ /** Resolve a `when <owner> becomes <state>` step anchor (ratchet D10). */
311
+ resolveStepAnchor(step) {
312
+ if (step.timing !== 'becomes' || !step.owner || !step.state)
313
+ return null;
314
+ const words = step.owner.words.map((w) => w.toLowerCase());
315
+ if (words.length === 1 && words[0] === 'story') {
316
+ if (!this.storyStates.includes(step.state)) {
317
+ this.diagnostics.error('analysis.undeclared-state', `\`${step.state}\` is not a declared state of the story${this.suggestText(step.state, this.storyStates)}.`, step.span);
318
+ }
319
+ return { owner: 'story', state: step.state };
320
+ }
321
+ const id = this.resolveEntityId(step.owner);
322
+ if (id === null)
323
+ return null; // already reported
324
+ const sym = this.byId.get(id);
325
+ if (sym && !sym.states.includes(step.state)) {
326
+ this.diagnostics.error('analysis.undeclared-state', `\`${step.state}\` is not a declared state of ${sym.nameLower}${this.suggestText(step.state, sym.states)}.`, step.span);
327
+ }
328
+ return { owner: id, state: step.state };
329
+ }
330
+ // ------------------------------------------------ Phase B declarations
331
+ buildTrait(decl) {
332
+ const fields = new Map(decl.data.map((f) => [f.name.join(' '), f]));
333
+ // States visible on `it`: the trait's own set plus every composer's
334
+ // full merged set (D8 cross-trait resolution — `restless` reads
335
+ // feedable's `hungry`).
336
+ const visible = this.traitVisibleStates.get(decl.name) ?? decl.states.map((s) => s.name);
337
+ const scope = {
338
+ owner: null,
339
+ fields,
340
+ slots: null,
341
+ ownStates: visible.length ? visible : null,
342
+ scoreOwner: `trait.${decl.name}`,
343
+ inEach: false,
344
+ };
345
+ return {
346
+ name: decl.name,
347
+ data: decl.data.map((f) => ({
348
+ name: f.name.join(' '),
349
+ type: f.type,
350
+ optional: f.optional,
351
+ initial: f.initial,
352
+ oneOf: f.oneOf,
353
+ })),
354
+ states: decl.states.map((s) => s.name),
355
+ statesReversible: decl.statesReversible,
356
+ scores: decl.scores.map((s) => ({ name: `trait.${decl.name}.${s.name}`, worth: s.worth, span: s.span })),
357
+ onClauses: this.checkDuplicateClauses(decl.onClauses, `trait \`${decl.name}\``).map((c) => this.buildOnClause(c, scope)),
358
+ span: decl.span,
359
+ };
360
+ }
361
+ /**
362
+ * Duplicate-clause gate (Phase C P3, adopted from the 2026-07-11 review):
363
+ * two clauses with the same (action, clauseKind, binding, role) on one
364
+ * owner silently mask at runtime (interceptor/capability registration is
365
+ * keyed) — a load error naming the first declaration. `on` vs `after` on
366
+ * the same action is legal (different lifecycle halves); every-turn
367
+ * clauses are exempt (daemons all fire). Event-verb clauses bind to the
368
+ * event stream individually — there the mask is per-condition, so a
369
+ * `while` condition differentiates (`after entering it while after-hours`
370
+ * beside `after entering it while not after-hours` is legal; two
371
+ * identically-conditioned clauses are not).
372
+ * Returns the clauses unchanged for fluent use.
373
+ */
374
+ checkDuplicateClauses(clauses, ownerDesc) {
375
+ const seen = new Map();
376
+ for (const clause of clauses) {
377
+ if (clause.binding === 'every-turn')
378
+ continue;
379
+ let key = `${clause.clauseKind}|${clause.action}|${clause.binding}|${clause.role ?? ''}`;
380
+ if (catalog_1.EVENT_VERBS.has(clause.action)) {
381
+ key += `|${clause.condition ? conditionFingerprint(clause.condition) : ''}`;
382
+ }
383
+ const first = seen.get(key);
384
+ if (first) {
385
+ this.diagnostics.error('analysis.duplicate-clause', `A \`${clause.clauseKind} ${clause.action}\` clause is already declared on ${ownerDesc} at line ${first.span.line} — a second one would silently mask it. Merge the bodies (or split intercept/react across \`on\`/\`after\`).`, clause.span);
386
+ continue;
387
+ }
388
+ seen.set(key, clause);
389
+ }
390
+ return clauses;
391
+ }
392
+ buildAction(decl) {
393
+ const slots = this.actionSlots.get(decl.name) ?? new Set();
394
+ const scope = { owner: null, fields: null, slots, ownStates: null, scoreOwner: `action.${decl.name}`, inEach: false };
395
+ for (const constraint of decl.constraints) {
396
+ if (!slots.has(constraint.slot)) {
397
+ this.diagnostics.error('analysis.unknown-slot', `\`${constraint.slot}\` is not a grammar slot of \`${decl.name}\` — slots: ${[...slots].join(', ') || '(none)'}${this.suggestText(constraint.slot, [...slots])}.`, constraint.span);
398
+ }
399
+ }
400
+ const refusals = decl.refusals.map((r) => {
401
+ this.requirePhrase(r.phraseKey, r.span, null);
402
+ if (r.kind === 'without') {
403
+ if (!slots.has(r.slot)) {
404
+ this.diagnostics.error('analysis.unknown-slot', `\`${r.slot}\` is not a grammar slot of \`${decl.name}\` — slots: ${[...slots].join(', ') || '(none)'}${this.suggestText(r.slot, [...slots])}.`, r.span);
405
+ }
406
+ return { kind: 'without', slot: r.slot, phraseKey: r.phraseKey, span: r.span };
407
+ }
408
+ this.checkRefusalPolarity(r.condition, r.span);
409
+ return {
410
+ kind: 'when',
411
+ condition: this.resolveCondition(r.condition, scope),
412
+ phraseKey: r.phraseKey,
413
+ span: r.span,
414
+ };
415
+ });
416
+ if (decl.otherwise)
417
+ this.requirePhrase(decl.otherwise.phraseKey, decl.otherwise.span, null);
418
+ const musts = decl.musts.map((m) => {
419
+ this.requirePhrase(m.phraseKey, m.span, null);
420
+ return {
421
+ condition: this.resolveCondition({ kind: 'predicate', subject: m.subject, predicate: m.predicate, span: m.span }, scope),
422
+ phraseKey: m.phraseKey,
423
+ span: m.span,
424
+ };
425
+ });
426
+ return {
427
+ name: decl.name,
428
+ patterns: decl.patterns.map((p) => ({
429
+ parts: p.parts.map((part) => ({ kind: part.kind, word: part.word })),
430
+ cardinality: p.cardinality,
431
+ })),
432
+ constraints: decl.constraints.map((sc) => ({ slot: sc.slot, requirement: sc.requirement })),
433
+ musts,
434
+ refusals,
435
+ otherwise: decl.otherwise?.phraseKey ?? null,
436
+ scores: decl.scores.map((s) => ({ name: `action.${decl.name}.${s.name}`, worth: s.worth, span: s.span })),
437
+ body: decl.body.map((s) => this.resolveStatement(s, scope)),
438
+ span: decl.span,
439
+ };
440
+ }
441
+ // -------------------------------------------------------------- pass 1
442
+ collect() {
443
+ // Story header: declared phases + story-owned scores (ratchet D2/D12).
444
+ if (this.ast.header) {
445
+ this.checkStateSet(this.ast.header.states, 'the story');
446
+ this.storyStates = this.ast.header.states.map((s) => s.name);
447
+ for (const s of this.ast.header.scores)
448
+ this.collectScore(s.name, s.worth, s.span, null);
449
+ }
450
+ for (const decl of this.ast.declarations) {
451
+ if (decl.kind === 'create')
452
+ this.collectEntity(decl);
453
+ else if (decl.kind === 'define-condition') {
454
+ this.conditionNames.add(decl.name);
455
+ this.openConditions.set(decl.name, conditionReferencesIt(decl.condition));
456
+ }
457
+ else if (decl.kind === 'define-text' || decl.kind === 'define-hatch') {
458
+ if (decl.name === 'br') {
459
+ this.diagnostics.error('analysis.reserved-marker', '`br` is reserved for the built-in `{br}` line-break marker — pick another producer name.', decl.span);
460
+ }
461
+ else {
462
+ this.hatchNames.add(decl.name);
463
+ }
464
+ }
465
+ else if (decl.kind === 'define-phrases')
466
+ this.collectPhrasesBlock(decl);
467
+ else if (decl.kind === 'define-phrase')
468
+ this.collectPhraseDecl(decl);
469
+ else if (decl.kind === 'define-trait') {
470
+ this.traitNames.add(decl.name);
471
+ for (const field of decl.data) {
472
+ // Trait data fields resolve in value positions — the reserved-
473
+ // name gate covers them like entity names (E3/P3).
474
+ if (field.name.join(' ').toLowerCase() === 'match') {
475
+ this.diagnostics.error('analysis.reserved-name', RESERVED_MATCH_MESSAGE, field.span);
476
+ }
477
+ }
478
+ if (decl.states.length) {
479
+ this.checkStateSet(decl.states, `trait \`${decl.name}\``);
480
+ this.traitStates.set(decl.name, decl.states.map((s) => s.name));
481
+ this.traitReversible.set(decl.name, decl.statesReversible);
482
+ }
483
+ for (const s of decl.scores)
484
+ this.collectScore(s.name, s.worth, s.span, `trait.${decl.name}`);
485
+ if (decl.phrases)
486
+ this.collectPhrasesBlock(decl.phrases);
487
+ for (const clause of decl.onClauses)
488
+ this.collectInlineTexts(clause.body);
489
+ }
490
+ else if (decl.kind === 'define-action') {
491
+ const slots = new Set();
492
+ for (const pattern of decl.patterns) {
493
+ for (const part of pattern.parts) {
494
+ if (part.kind !== 'slot')
495
+ continue;
496
+ // Grammar slots resolve in value positions — same reserved-
497
+ // name gate as entity names (E3/P3).
498
+ if (part.word.toLowerCase() === 'match') {
499
+ this.diagnostics.error('analysis.reserved-name', RESERVED_MATCH_MESSAGE, part.span);
500
+ }
501
+ slots.add(part.word);
502
+ }
503
+ }
504
+ this.actionSlots.set(decl.name, slots);
505
+ for (const s of decl.scores)
506
+ this.collectScore(s.name, s.worth, s.span, `action.${decl.name}`);
507
+ if (decl.phrases)
508
+ this.collectPhrasesBlock(decl.phrases);
509
+ this.collectInlineTexts(decl.body);
510
+ }
511
+ else if (decl.kind === 'define-sequence') {
512
+ for (const step of decl.steps)
513
+ this.collectInlineTexts(step.body);
514
+ }
515
+ }
516
+ // Trait-declared states reach every composer (ratchet D8): merge each
517
+ // trait's state set into the composing entity's, in composition order.
518
+ // The same state name arriving from two sources is the D8 collision
519
+ // gate — states are one namespace per entity.
520
+ for (const e of this.entities) {
521
+ for (const comp of e.decl.compositions) {
522
+ if (comp.article)
523
+ continue; // kind noun, not a trait
524
+ const traitName = comp.words.join(' ').toLowerCase();
525
+ const states = this.traitStates.get(traitName);
526
+ if (!states)
527
+ continue;
528
+ for (const s of states) {
529
+ const existing = e.stateSource.get(s);
530
+ if (existing !== undefined && existing !== traitName) {
531
+ const from = existing === 'own' ? `its own \`states:\` line` : `\`${existing}\``;
532
+ this.diagnostics.error('analysis.state-collision', `State \`${s}\` reaches ${e.nameLower} from both ${from} and \`${traitName}\` — states are one namespace per entity; rename one.`, comp.span);
533
+ continue;
534
+ }
535
+ if (existing === undefined) {
536
+ e.states.push(s);
537
+ e.stateSource.set(s, traitName);
538
+ }
539
+ }
540
+ }
541
+ }
542
+ // Cross-trait visibility (D8): inside a trait's clauses, `it` may
543
+ // reference any state on any composer's full merged set — `restless`
544
+ // reads feedable's `hungry` without declaring it.
545
+ for (const [t, own] of this.traitStates)
546
+ this.traitVisibleStates.set(t, [...own]);
547
+ for (const e of this.entities) {
548
+ for (const comp of e.decl.compositions) {
549
+ if (comp.article)
550
+ continue;
551
+ const t = comp.words.join(' ').toLowerCase();
552
+ if (!this.traitNames.has(t))
553
+ continue;
554
+ let vis = this.traitVisibleStates.get(t);
555
+ if (!vis) {
556
+ vis = [];
557
+ this.traitVisibleStates.set(t, vis);
558
+ }
559
+ for (const s of e.states)
560
+ if (!vis.includes(s))
561
+ vis.push(s);
562
+ }
563
+ }
564
+ // Derived phrase keys: entity descriptions and per-entity overrides.
565
+ for (const e of this.entities) {
566
+ if (e.decl.description) {
567
+ this.registerPhrase(DEFAULT_LOCALE, `${e.id}.description`, {
568
+ strategy: null,
569
+ variants: [this.variantOf(e.decl.description)],
570
+ span: e.decl.description.span,
571
+ });
572
+ }
573
+ if (e.decl.initialDescription) {
574
+ // Z1: the `first time` prose — first-visit description, its own key.
575
+ this.registerPhrase(DEFAULT_LOCALE, `${e.id}.initial-description`, {
576
+ strategy: null,
577
+ variants: [this.variantOf(e.decl.initialDescription)],
578
+ span: e.decl.initialDescription.span,
579
+ });
580
+ }
581
+ let detailIndex = 0;
582
+ for (const override of e.decl.phraseOverrides) {
583
+ const isDetail = override.key === 'detail';
584
+ if (isDetail)
585
+ detailIndex++;
586
+ // Z3b: multiple `detail` blocks per owner are legal (the one place a
587
+ // key repeats) — later blocks get deterministic suffixed keys in
588
+ // declaration order.
589
+ const key = isDetail && detailIndex > 1 ? `${e.id}.detail.${detailIndex}` : `${e.id}.${override.key}`;
590
+ if (isDetail && !override.condition) {
591
+ this.diagnostics.error('analysis.detail-unconditional', '`phrase detail` needs a `while <condition>` — unconditional detail belongs in the description (Z3b).', override.span);
592
+ }
593
+ if (isDetail && (override.variants.length > 1 || override.strategy)) {
594
+ this.diagnostics.error('analysis.detail-variants', '`phrase detail` is one gated text per block — write another `phrase detail while …:` block for variety (Z3b).', override.span);
595
+ }
596
+ // Never-guess: `while` on the lifecycle channels (entered/exited/
597
+ // disappeared) and on ordinary overrides has no pinned semantics;
598
+ // `present` gates ride the ADR-212 predicate seam, `detail` gates are
599
+ // Z3b's whole point.
600
+ if (override.condition && !isDetail && override.key !== 'present') {
601
+ this.diagnostics.error('analysis.override-gate', `\`while\` on \`phrase ${override.key}:\` has no defined semantics — only \`detail\` and \`present\` blocks take a gate.`, override.span);
602
+ }
603
+ this.registerPhrase(DEFAULT_LOCALE, key, {
604
+ strategy: override.strategy ?? null,
605
+ ...(override.condition ? { condition: this.resolveCondition(override.condition, entityScope(e)) } : {}),
606
+ variants: override.variants.map((v) => this.variantOf(v)),
607
+ span: override.span,
608
+ });
609
+ }
610
+ // Entity-owned clauses register their inline phrases under the
611
+ // owner-derived key (phrase-override mechanism) — four owners each
612
+ // declaring `phrase confession` must not collide (Phase C P3).
613
+ for (const clause of e.decl.onClauses)
614
+ this.collectInlineTexts(clause.body, e.id);
615
+ }
616
+ }
617
+ /**
618
+ * Declare-and-emit sugar (§2.6): a `phrase <key>` statement carrying an
619
+ * indented prose block registers the key at load — collected in pass 1 so
620
+ * pass 2's phrase-coverage gate sees it. Inside an entity-owned clause the
621
+ * key registers owner-scoped (`<entity-id>.<key>`, the phrase-override
622
+ * mechanism) so per-owner keys don't collide; ownerless scopes (traits,
623
+ * actions, sequences) keep the bare key.
624
+ */
625
+ collectInlineTexts(body, ownerId = null) {
626
+ for (const stmt of body) {
627
+ switch (stmt.kind) {
628
+ case 'phrase':
629
+ if (stmt.inlineText) {
630
+ this.registerPhrase(DEFAULT_LOCALE, ownerId ? `${ownerId}.${stmt.phraseKey}` : stmt.phraseKey, {
631
+ strategy: null,
632
+ variants: [this.variantOf(stmt.inlineText)],
633
+ span: stmt.inlineText.span,
634
+ });
635
+ }
636
+ break;
637
+ case 'select-on':
638
+ for (const arm of stmt.arms)
639
+ this.collectInlineTexts(arm.body, ownerId);
640
+ break;
641
+ case 'select-strategy':
642
+ for (const alt of stmt.alternatives)
643
+ this.collectInlineTexts(alt, ownerId);
644
+ break;
645
+ case 'ordinal':
646
+ this.collectInlineTexts(stmt.body, ownerId);
647
+ break;
648
+ case 'each':
649
+ this.collectInlineTexts(stmt.body, ownerId);
650
+ break;
651
+ default:
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ // ------------------------------------------------- state-set gates (D9)
657
+ /**
658
+ * The three-ring boolean-state gate (decision 8, ratchet D9) — pattern
659
+ * detection over every declared state set (story, trait, entity). Ring 1:
660
+ * literal booleans. Ring 2: a set reproducing a platform-owned pair.
661
+ * Ring 3: a state named as the negation of a sibling.
662
+ */
663
+ checkStateSet(states, ownerDesc) {
664
+ const names = states.map((s) => s.name);
665
+ for (const s of states) {
666
+ if (BOOLEAN_STATE_WORDS.has(s.name)) {
667
+ 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
+ }
669
+ }
670
+ for (const { pair, trait } of catalog_1.PLATFORM_STATE_PAIRS) {
671
+ if (names.includes(pair[0]) && names.includes(pair[1])) {
672
+ const at = states.find((s) => s.name === pair[1]) ?? states[0];
673
+ 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);
674
+ }
675
+ }
676
+ for (const a of states) {
677
+ for (const b of states) {
678
+ if (a === b)
679
+ continue;
680
+ if (isNegationOf(b.name, a.name)) {
681
+ this.diagnostics.error('analysis.negated-state', `\`${b.name}\` names the absence of \`${a.name}\`, not a condition of ${ownerDesc}. Name what it IS when not ${a.name} — feedable's answer was \`hungry\`/\`content\`. A state names what the thing IS, never the absence of another state.`, b.span);
682
+ }
683
+ }
684
+ }
685
+ }
686
+ /**
687
+ * The declared set a state belongs to on an entity, with its
688
+ * forward-march policy (D4) — null when undeterminable.
689
+ */
690
+ stateSetOf(sym, state) {
691
+ if (sym) {
692
+ const source = sym.stateSource.get(state);
693
+ if (source === 'own') {
694
+ return { states: sym.decl.states.map((s) => s.name), reversible: sym.ownReversible };
695
+ }
696
+ if (source) {
697
+ const states = this.traitStates.get(source);
698
+ return states ? { states, reversible: this.traitReversible.get(source) ?? false } : null;
699
+ }
700
+ return null;
701
+ }
702
+ // Trait scope (`it` with no concrete owner): the declaring trait's set.
703
+ for (const [t, states] of this.traitStates) {
704
+ if (states.includes(state))
705
+ return { states, reversible: this.traitReversible.get(t) ?? false };
706
+ }
707
+ return null;
708
+ }
709
+ /**
710
+ * D4 forward-march, statically provable half: a `change` targeting the
711
+ * INITIAL state of a non-reversible set can never be a forward move.
712
+ * (The runtime enforces full ordering; this is the load-time gate.)
713
+ */
714
+ checkChangeLegality(info, state, span) {
715
+ if (info && !info.reversible && info.states.length > 0 && info.states[0] === state) {
716
+ this.diagnostics.error('analysis.irreversible-state', `\`${state}\` is the initial state of a forward-only set — \`change\` cannot go back. Add \`, reversible\` to the \`states:\` line to permit back-transitions (D4).`, span);
717
+ }
718
+ }
719
+ collectEntity(decl) {
720
+ const nameWords = decl.name.words;
721
+ const id = nameWords.join('-').toLowerCase();
722
+ // `match` is a reserved value-position name (David, 2026-07-12 —
723
+ // each package P3): it is the `each`-block binder (`the match`, E3),
724
+ // resolved before entity lookup exactly as `it` is. Multi-word names
725
+ // containing the word stay legal.
726
+ if (id === 'match') {
727
+ this.diagnostics.error('analysis.reserved-name', RESERVED_MATCH_MESSAGE, decl.name.span);
728
+ return;
729
+ }
730
+ for (const alias of decl.aka) {
731
+ if (alias.toLowerCase() === 'match') {
732
+ this.diagnostics.error('analysis.reserved-name', RESERVED_MATCH_MESSAGE, decl.name.span);
733
+ }
734
+ }
735
+ if (this.byId.has(id)) {
736
+ this.diagnostics.error('analysis.duplicate-entity', `An entity named \`${nameWords.join(' ')}\` already exists.`, decl.name.span);
737
+ return;
738
+ }
739
+ this.checkStateSet(decl.states, nameWords.join(' ').toLowerCase());
740
+ const sym = {
741
+ id,
742
+ nameLower: nameWords.join(' ').toLowerCase(),
743
+ nameWords: nameWords.map((w) => w.toLowerCase()),
744
+ aka: decl.aka.map((a) => a.toLowerCase()),
745
+ states: decl.states.map((s) => s.name),
746
+ stateSource: new Map(decl.states.map((s) => [s.name, 'own'])),
747
+ ownReversible: decl.statesReversible,
748
+ decl,
749
+ };
750
+ this.entities.push(sym);
751
+ this.byId.set(id, sym);
752
+ for (const s of decl.scores)
753
+ this.collectScore(s.name, s.worth, s.span, id);
754
+ }
755
+ /** Register an owner-attached score (ratchet D12) under its qualified id. */
756
+ collectScore(name, worth, span, ownerKey) {
757
+ const qualified = ownerKey ? `${ownerKey}.${name}` : name;
758
+ if (this.scoreNames.has(qualified)) {
759
+ this.diagnostics.error('analysis.duplicate-score', `Score \`${name}\` is declared twice on this owner.`, span);
760
+ return;
761
+ }
762
+ this.scoreNames.set(qualified, worth);
763
+ this.scoreDecls.push({ name: qualified, worth, span });
764
+ }
765
+ collectPhrasesBlock(decl) {
766
+ for (const entry of decl.entries) {
767
+ this.registerPhrase(decl.locale, entry.key, {
768
+ strategy: null,
769
+ variants: [this.variantOf(entry.value)],
770
+ span: entry.span,
771
+ });
772
+ }
773
+ }
774
+ collectPhraseDecl(decl) {
775
+ this.registerPhrase(DEFAULT_LOCALE, decl.key, {
776
+ strategy: decl.strategy ?? null,
777
+ ...(decl.verbatim ? { verbatim: true } : {}),
778
+ variants: decl.variants.map((v) => this.variantOf(v)),
779
+ span: decl.span,
780
+ });
781
+ }
782
+ variantOf(value) {
783
+ return { text: value.text, markers: value.markers.map((m) => m.content) };
784
+ }
785
+ registerPhrase(locale, key, phrase) {
786
+ if (key === 'br') {
787
+ // `{br}` is the built-in hard-line-break marker (grammar log 2026-07-10).
788
+ this.diagnostics.error('analysis.reserved-marker', '`br` is reserved for the built-in `{br}` line-break marker — pick another phrase name.', phrase.span);
789
+ return;
790
+ }
791
+ if (RESERVED_CHANNEL_KEYS.has(key)) {
792
+ // Z3/Z3b: channel keys are entity-owned surfaces — bare declarations
793
+ // shadow the channel. (Owner-scoped `<id>.<key>` registrations are the
794
+ // channels themselves and pass through here untouched.)
795
+ this.diagnostics.error('analysis.reserved-name', `\`${key}\` is a reserved channel key — author it as an entity \`phrase ${key}:\` block, never as a standalone phrase.`, phrase.span);
796
+ return;
797
+ }
798
+ let table = this.phrases.get(locale);
799
+ if (!table) {
800
+ table = new Map();
801
+ this.phrases.set(locale, table);
802
+ }
803
+ if (table.has(key)) {
804
+ this.diagnostics.error('analysis.duplicate-phrase', `Phrase \`${key}\` is declared twice in ${locale}.`, phrase.span);
805
+ return;
806
+ }
807
+ table.set(key, phrase);
808
+ }
809
+ // ------------------------------------------------------------- entities
810
+ buildEntity(decl) {
811
+ const sym = this.byId.get(decl.name.words.join('-').toLowerCase());
812
+ const id = sym?.id ?? decl.name.words.join('-').toLowerCase();
813
+ const isPlayer = decl.name.words.length === 1 && decl.name.words[0].toLowerCase() === 'player';
814
+ const kinds = [];
815
+ const traits = [];
816
+ for (const comp of decl.compositions) {
817
+ const built = {
818
+ name: comp.words.join(' ').toLowerCase(),
819
+ config: comp.config.map((c) => ({ key: c.key.join(' '), value: c.value, valueKind: c.valueKind })),
820
+ condition: comp.condition ? this.resolveCondition(comp.condition, entityScope(sym ?? null)) : null,
821
+ span: comp.span,
822
+ };
823
+ if (comp.article)
824
+ kinds.push(built);
825
+ else
826
+ traits.push(built);
827
+ }
828
+ // Z1: `first time` prose compiles to RoomTrait.initialDescription —
829
+ // only rooms carry that field, so any other kind is a load error
830
+ // until a platform surface exists (never a guess).
831
+ if (decl.initialDescription && !kinds.some((k) => k.name === 'room')) {
832
+ this.diagnostics.error('analysis.first-time-non-room', `\`first time\` prose is only supported on rooms (it compiles to RoomTrait.initialDescription) — \`${decl.name.words.join(' ')}\` is not a room.`, decl.initialDescription.span);
833
+ }
834
+ return {
835
+ id,
836
+ name: decl.name.words.join(' '),
837
+ article: decl.name.article,
838
+ aka: decl.aka,
839
+ isPlayer,
840
+ kinds,
841
+ traits,
842
+ placement: decl.placement
843
+ ? {
844
+ relation: decl.placement.relation,
845
+ place: this.resolveEntityId(decl.placement.place) ?? '',
846
+ span: decl.placement.span,
847
+ }
848
+ : null,
849
+ 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 })),
851
+ blockedExits: decl.blockedExits.map((b) => {
852
+ this.requirePhrase(b.phraseKey, b.span);
853
+ return {
854
+ direction: b.direction,
855
+ phraseKey: b.phraseKey,
856
+ condition: b.condition ? this.resolveCondition(b.condition, entityScope(sym ?? null)) : null,
857
+ span: b.span,
858
+ };
859
+ }),
860
+ // Merged set: own `states:` plus every composed trait's declared
861
+ // states (ratchet D8) — the loader initializes from states[0].
862
+ states: sym ? sym.states : decl.states.map((s) => s.name),
863
+ statesReversible: decl.statesReversible,
864
+ descriptionKey: decl.description ? `${id}.description` : null,
865
+ initialDescriptionKey: decl.initialDescription ? `${id}.initial-description` : null,
866
+ onClauses: this.checkDuplicateClauses(decl.onClauses, decl.name.words.join(' ').toLowerCase()).map((c) => this.buildOnClause(c, entityScope(sym ?? null))),
867
+ span: decl.span,
868
+ };
869
+ }
870
+ buildOnClause(clause, scope) {
871
+ // §5.4 compiler rule, both halves: clauses on dispatch verbs (`define
872
+ // action` names) compile to CapabilityBehaviors; clauses on standard-
873
+ // semantics actions compile to ActionInterceptors (the Phase A path).
874
+ let routing = null;
875
+ if (clause.binding !== 'every-turn') {
876
+ routing = this.actionSlots.has(clause.action) ? 'capability' : 'interceptor';
877
+ }
878
+ // Clauses bound to a dispatch verb may reference its grammar slots
879
+ // (`the animal`); role clauses additionally bind the role word itself.
880
+ const extraSlots = new Set(scope.slots ?? []);
881
+ for (const slot of this.actionSlots.get(clause.action) ?? [])
882
+ extraSlots.add(slot);
883
+ if (clause.binding === 'role' && clause.role) {
884
+ const roles = this.rolesFor(clause.action);
885
+ if (!roles.has(clause.role)) {
886
+ this.diagnostics.error('analysis.unknown-role', `\`${clause.role}\` is not a role of \`${clause.action}\` — roles: ${[...roles].join(', ') || '(none)'}${this.suggestText(clause.role, [...roles])}.`, clause.span);
887
+ }
888
+ extraSlots.add(clause.role);
889
+ }
890
+ const clauseScope = { ...scope, slots: extraSlots.size ? extraSlots : scope.slots };
891
+ const condition = clause.condition ? this.resolveCondition(clause.condition, clauseScope) : null;
892
+ const body = clause.body.map((s) => this.resolveStatement(s, clauseScope));
893
+ this.checkPhaseOrder(clause.body, { mutated: false });
894
+ return {
895
+ clauseKind: clause.clauseKind,
896
+ once: clause.once,
897
+ action: clause.action,
898
+ binding: clause.binding,
899
+ role: clause.role,
900
+ condition,
901
+ ordering: clause.ordering,
902
+ routing,
903
+ // Decision 10: all on-clauses are entity/trait-owned (ownership
904
+ // package) — their narration is presence-scoped.
905
+ narration: 'presence',
906
+ body,
907
+ span: clause.span,
908
+ };
909
+ }
910
+ /** Roles a clause may bind on an action: declared slots + the actor role. */
911
+ rolesFor(action) {
912
+ const declared = this.actionSlots.get(action);
913
+ if (declared)
914
+ return new Set([...declared, 'actor']);
915
+ return new Set(STANDARD_ACTION_ROLES[action] ?? []);
916
+ }
917
+ /**
918
+ * Phase-order rule (§5.3 gate 4): within an `on` block, `refuse` must
919
+ * precede the first mutation. Traversal is source order, descending into
920
+ * nested blocks.
921
+ */
922
+ checkPhaseOrder(body, state) {
923
+ for (const stmt of body) {
924
+ switch (stmt.kind) {
925
+ case 'set':
926
+ case 'change':
927
+ case 'move':
928
+ case 'remove':
929
+ case 'award':
930
+ state.mutated = true;
931
+ break;
932
+ case 'refuse':
933
+ case 'must':
934
+ case 'refuse-when':
935
+ if (state.mutated) {
936
+ this.diagnostics.error('analysis.refusal-after-mutation', `Refusal after mutation — move the check above the first set/change/move (\`${stmt.kind === 'must' ? 'must' : `refuse ${stmt.phraseKey}`}\` must precede mutations).`, stmt.span);
937
+ }
938
+ break;
939
+ case 'select-on':
940
+ for (const arm of stmt.arms)
941
+ this.checkPhaseOrder(arm.body, state);
942
+ break;
943
+ case 'select-strategy':
944
+ for (const alt of stmt.alternatives)
945
+ this.checkPhaseOrder(alt, state);
946
+ break;
947
+ case 'ordinal':
948
+ this.checkPhaseOrder(stmt.body, state);
949
+ break;
950
+ case 'each':
951
+ // The block's body mutates per match — refusals after it (or
952
+ // inside it, after a mutation) violate the phase order.
953
+ this.checkPhaseOrder(stmt.body, state);
954
+ break;
955
+ default:
956
+ break;
957
+ }
958
+ }
959
+ }
960
+ // ----------------------------------------------------------- statements
961
+ resolveStatement(stmt, scope) {
962
+ switch (stmt.kind) {
963
+ case 'refuse':
964
+ case 'phrase': {
965
+ if (RESERVED_CHANNEL_KEYS.has(stmt.phraseKey)) {
966
+ // Z3: channels are platform-PULLED — emitting one via a `phrase`
967
+ // (or `refuse`) statement is a load error, never a push.
968
+ this.diagnostics.error('analysis.channel-pushed', `\`${stmt.phraseKey}\` is a platform-pulled channel — it narrates when its platform condition fires, never via a \`${stmt.kind}\` statement.`, stmt.span);
969
+ }
970
+ this.requirePhrase(stmt.phraseKey, stmt.span, scope.owner);
971
+ const params = stmt.params.map((p) => ({
972
+ param: p.param.join(' '),
973
+ value: this.resolveValue(p.value, scope),
974
+ span: p.span,
975
+ }));
976
+ if (stmt.kind === 'refuse') {
977
+ return { kind: 'refuse', phraseKey: stmt.phraseKey, params, span: stmt.span };
978
+ }
979
+ return {
980
+ kind: 'phrase',
981
+ phraseKey: stmt.phraseKey,
982
+ params,
983
+ stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
984
+ span: stmt.span,
985
+ };
986
+ }
987
+ case 'emit':
988
+ return { kind: 'emit', event: stmt.event.join(' '), stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
989
+ case 'set':
990
+ return {
991
+ kind: 'set',
992
+ target: this.resolveValue(stmt.target, scope),
993
+ value: this.resolveValue(stmt.value, scope),
994
+ span: stmt.span,
995
+ };
996
+ case 'change': {
997
+ // `change the story to <state>` targets the story object (D2).
998
+ const targetWords = stmt.entity.words.map((w) => w.toLowerCase());
999
+ if (targetWords.length === 1 && targetWords[0] === 'story') {
1000
+ if (!this.storyStates.includes(stmt.state)) {
1001
+ this.diagnostics.error('analysis.undeclared-state', `\`${stmt.state}\` is not a declared state of the story${this.suggestText(stmt.state, this.storyStates)}.`, stmt.span);
1002
+ }
1003
+ else {
1004
+ this.checkChangeLegality({ states: this.storyStates, reversible: this.ast.header?.statesReversible ?? false }, stmt.state, stmt.span);
1005
+ }
1006
+ return { kind: 'change', entity: { kind: 'story' }, state: stmt.state, stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
1007
+ }
1008
+ const entity = this.resolveEntityValue(stmt.entity, scope);
1009
+ const sym = entity.kind === 'entity' ? this.byId.get(entity.id) : entity.kind === 'it' ? scope.owner : null;
1010
+ const validStates = sym ? sym.states : entity.kind === 'it' ? scope.ownStates : null;
1011
+ if (validStates && !validStates.includes(stmt.state)) {
1012
+ this.diagnostics.error('analysis.undeclared-state', `\`${stmt.state}\` is not a declared state of ${sym?.nameLower ?? 'it'}${this.suggestText(stmt.state, validStates)}.`, stmt.span);
1013
+ }
1014
+ else if (validStates) {
1015
+ this.checkChangeLegality(this.stateSetOf(sym ?? null, stmt.state), stmt.state, stmt.span);
1016
+ }
1017
+ return { kind: 'change', entity, state: stmt.state, stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
1018
+ }
1019
+ case 'remove': {
1020
+ // Z6 (ADR-213 Q3): entity references resolve as `move`'s do; the
1021
+ // player is never removable (`analysis.remove-player` — the platform
1022
+ // defines no post-removal player semantics).
1023
+ const entity = this.resolveEntityValue(stmt.entity, scope);
1024
+ if (entity.kind === 'player' ||
1025
+ (entity.kind === 'entity' && this.byId.get(entity.id)?.decl.name.words.join(' ').toLowerCase() === 'player')) {
1026
+ this.diagnostics.error('analysis.remove-player', '`remove the player` is not a thing — the platform defines no post-removal player semantics (ADR-213).', stmt.span);
1027
+ }
1028
+ return {
1029
+ kind: 'remove',
1030
+ entity,
1031
+ stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
1032
+ span: stmt.span,
1033
+ };
1034
+ }
1035
+ case 'move':
1036
+ return {
1037
+ kind: 'move',
1038
+ entity: this.resolveEntityValue(stmt.entity, scope),
1039
+ place: this.resolveEntityValue(stmt.place, scope),
1040
+ stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope),
1041
+ span: stmt.span,
1042
+ };
1043
+ case 'award': {
1044
+ // `award <score-name>` resolves owner-first (ratchet D12): the
1045
+ // enclosing owner's qualified id, then the story-level bare name.
1046
+ let expression = stmt.expression;
1047
+ if (stmt.expression.length === 1 && !stmt.expression[0].includes("'")) {
1048
+ const name = stmt.expression[0];
1049
+ const qualified = scope.scoreOwner ? `${scope.scoreOwner}.${name}` : null;
1050
+ if (qualified && this.scoreNames.has(qualified)) {
1051
+ expression = [qualified];
1052
+ }
1053
+ else if (!this.scoreNames.has(name) && this.scoreNames.size > 0) {
1054
+ this.diagnostics.error('analysis.unknown-score', `\`${name}\` is not a declared score of this owner or the story${this.suggestText(name, [...this.scoreNames.keys()].map((k) => k.split('.').pop()))}.`, stmt.span);
1055
+ }
1056
+ }
1057
+ return { kind: 'award', expression, once: stmt.once, stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
1058
+ }
1059
+ case 'win':
1060
+ case 'lose':
1061
+ if (stmt.phraseKey)
1062
+ this.requirePhrase(stmt.phraseKey, stmt.span, scope.owner);
1063
+ return { kind: stmt.kind, phraseKey: stmt.phraseKey, stmtWhen: this.resolveStmtWhen(stmt.stmtWhen, scope), span: stmt.span };
1064
+ case 'must': {
1065
+ // `<subject> must <predicate>: <key>` (ratchet D6) — a positive
1066
+ // requirement; compiled as its predicate condition plus the key.
1067
+ this.requirePhrase(stmt.phraseKey, stmt.span, scope.owner);
1068
+ return {
1069
+ kind: 'must',
1070
+ condition: this.resolveCondition({ kind: 'predicate', subject: stmt.subject, predicate: stmt.predicate, span: stmt.span }, scope),
1071
+ phraseKey: stmt.phraseKey,
1072
+ span: stmt.span,
1073
+ };
1074
+ }
1075
+ case 'refuse-when': {
1076
+ // Prohibition (D6): refuse with the key while the hazard holds.
1077
+ this.checkRefusalPolarity(stmt.condition, stmt.span);
1078
+ this.requirePhrase(stmt.phraseKey, stmt.span, scope.owner);
1079
+ return {
1080
+ kind: 'refuse-when',
1081
+ condition: this.resolveCondition(stmt.condition, scope),
1082
+ phraseKey: stmt.phraseKey,
1083
+ span: stmt.span,
1084
+ };
1085
+ }
1086
+ case 'select-on': {
1087
+ const subject = this.resolveValue(stmt.subject, scope);
1088
+ const stateOwner = this.stateOwnerOf(subject, scope);
1089
+ // Trait scope: `select on its state` validates against the visible
1090
+ // state set (own + cross-trait, D8) with no concrete owner entity.
1091
+ const traitStates = !stateOwner && subject.kind === 'field' && subject.field === 'state' && subject.base.kind === 'it'
1092
+ ? scope.ownStates
1093
+ : null;
1094
+ const fieldValues = this.fieldValueSet(subject, scope);
1095
+ for (const arm of stmt.arms) {
1096
+ if (stateOwner && !stateOwner.states.includes(arm.value)) {
1097
+ this.diagnostics.error('analysis.undeclared-state', `\`${arm.value}\` is not a declared state of ${stateOwner.nameLower}${this.suggestText(arm.value, stateOwner.states)}.`, arm.span);
1098
+ }
1099
+ else if (traitStates && !traitStates.includes(arm.value)) {
1100
+ this.diagnostics.error('analysis.undeclared-state', `\`${arm.value}\` is not a declared state of \`it\` here${this.suggestText(arm.value, traitStates)}.`, arm.span);
1101
+ }
1102
+ else if (fieldValues && !fieldValues.includes(arm.value)) {
1103
+ this.diagnostics.error('analysis.unknown-value', `\`${arm.value}\` is not a value of this field${this.suggestText(arm.value, fieldValues)}.`, arm.span);
1104
+ }
1105
+ }
1106
+ return {
1107
+ kind: 'select-on',
1108
+ subject,
1109
+ arms: stmt.arms.map((a) => ({
1110
+ value: a.value,
1111
+ body: a.body.map((s) => this.resolveStatement(s, scope)),
1112
+ span: a.span,
1113
+ })),
1114
+ span: stmt.span,
1115
+ };
1116
+ }
1117
+ case 'select-strategy':
1118
+ return {
1119
+ kind: 'select-strategy',
1120
+ strategy: stmt.strategy,
1121
+ alternatives: stmt.alternatives.map((alt) => alt.map((s) => this.resolveStatement(s, scope))),
1122
+ span: stmt.span,
1123
+ };
1124
+ case 'ordinal':
1125
+ return {
1126
+ kind: 'ordinal',
1127
+ ordinal: stmt.ordinal,
1128
+ body: stmt.body.map((s) => this.resolveStatement(s, scope)),
1129
+ span: stmt.span,
1130
+ };
1131
+ case 'each': {
1132
+ // E3 (ratchet 2026-07-12): body-position iteration over a named
1133
+ // OPEN condition — the same never-guess gate as `any`/`no`. The
1134
+ // body resolves with the binder in scope; `it` keeps meaning the
1135
+ // clause owner (no shadowing), so the scope is otherwise unchanged.
1136
+ this.requireOpenCondition(stmt.condition, stmt.span, 'each');
1137
+ const eachScope = { ...scope, inEach: true };
1138
+ return {
1139
+ kind: 'each',
1140
+ condition: stmt.condition,
1141
+ body: stmt.body.map((s) => this.resolveStatement(s, eachScope)),
1142
+ span: stmt.span,
1143
+ };
1144
+ }
1145
+ }
1146
+ }
1147
+ /** Resolve a statement `when` suffix (ratchet D7), or null. */
1148
+ resolveStmtWhen(cond, scope) {
1149
+ return cond ? this.resolveCondition(cond, scope) : null;
1150
+ }
1151
+ /**
1152
+ * D6 polarity gate: `refuse when` states a hazard that is PRESENT — a
1153
+ * top-level `not` inverts a requirement, and requirements have one
1154
+ * canonical form (`must`).
1155
+ */
1156
+ checkRefusalPolarity(cond, span) {
1157
+ if (cond.kind === 'not') {
1158
+ this.diagnostics.error('analysis.negated-requirement', 'State requirements positively — `refuse when not …` is not a form. Write `<subject> must <predicate>: <phrase-key>` for the requirement; `refuse when` is for hazards that are present.', span);
1159
+ }
1160
+ }
1161
+ /** The entity whose `states:` list governs a select-on subject, if determinable. */
1162
+ stateOwnerOf(subject, scope) {
1163
+ if (subject.kind === 'field' && subject.field === 'state') {
1164
+ if (subject.base.kind === 'it')
1165
+ return scope.owner;
1166
+ if (subject.base.kind === 'entity')
1167
+ return this.byId.get(subject.base.id) ?? null;
1168
+ }
1169
+ return null;
1170
+ }
1171
+ // ------------------------------------------------------------- values
1172
+ resolveValue(expr, scope) {
1173
+ switch (expr.kind) {
1174
+ case 'literal':
1175
+ return { kind: 'literal', value: expr.value, valueType: expr.literalKind };
1176
+ case 'possessive':
1177
+ return {
1178
+ kind: 'field',
1179
+ base: this.resolveValue(expr.base, scope),
1180
+ field: expr.field.join(' '),
1181
+ };
1182
+ case 'ref':
1183
+ return this.resolveRefValue(expr.ref, scope);
1184
+ case 'bare': {
1185
+ const scoped = this.resolveScopedWords(expr.words, scope);
1186
+ return scoped ?? { kind: 'symbol', name: expr.words.join(' ') };
1187
+ }
1188
+ case 'match':
1189
+ return this.resolveMatch(expr.span, scope);
1190
+ }
1191
+ }
1192
+ /**
1193
+ * `the match` — the `each`-block binder (ratchet E3). Legal only inside
1194
+ * an `each` body, at any nesting depth (the runtime binds innermost).
1195
+ * Outside one there is no match to reference — a load error, never a
1196
+ * guess (`analysis.match-outside-each`).
1197
+ */
1198
+ resolveMatch(span, scope) {
1199
+ if (!scope.inEach) {
1200
+ this.diagnostics.error('analysis.match-outside-each', '`the match` is the `each`-block binder — outside an `each` body there is no match to reference. Use `it` for the clause owner, or name the entity.', span);
1201
+ }
1202
+ return { kind: 'match' };
1203
+ }
1204
+ /**
1205
+ * Scope-aware resolution of a bare/`the`-led word sequence (Phase B):
1206
+ * trait data fields read as `its <field>`; grammar slots and role words
1207
+ * are context values; declared flags are flag reads. Null = not scoped.
1208
+ */
1209
+ resolveScopedWords(rawWords, scope) {
1210
+ const joined = rawWords.join(' ').toLowerCase();
1211
+ if (scope.fields?.has(joined)) {
1212
+ return { kind: 'field', base: { kind: 'it' }, field: joined };
1213
+ }
1214
+ if (rawWords.length === 1) {
1215
+ const word = rawWords[0].toLowerCase();
1216
+ if (scope.slots?.has(word))
1217
+ return { kind: 'slot', name: word };
1218
+ // `the actor` — the acting entity, always bound inside trait/action
1219
+ // clauses (design.md §2.2 role vocabulary).
1220
+ if (word === 'actor' && (scope.fields !== null || scope.slots !== null)) {
1221
+ return { kind: 'slot', name: 'actor' };
1222
+ }
1223
+ }
1224
+ return null;
1225
+ }
1226
+ resolveRefValue(ref, scope) {
1227
+ const words = ref.words.map((w) => w.toLowerCase());
1228
+ if (words.length === 1 && words[0] === 'it')
1229
+ return { kind: 'it' };
1230
+ // `the match` in NameRef positions (`change`/`move` targets, predicate
1231
+ // things) resolves to the binder exactly as `it` does — before entity
1232
+ // lookup; the name itself is reserved at declaration (E3/P3).
1233
+ if (words.length === 1 && words[0] === 'match')
1234
+ return this.resolveMatch(ref.span, scope);
1235
+ if (words.length === 1 && PLAYER_WORDS.has(words[0]))
1236
+ return { kind: 'player' };
1237
+ // Boolean words are symbols, not entity lookups (`set fed to true`).
1238
+ if (words.length === 1 && (words[0] === 'true' || words[0] === 'false')) {
1239
+ return { kind: 'symbol', name: words[0] };
1240
+ }
1241
+ // `its <field>` in name position (`the actor has its food`).
1242
+ if (words.length > 1 && words[0] === 'its') {
1243
+ return { kind: 'field', base: { kind: 'it' }, field: words.slice(1).join(' ') };
1244
+ }
1245
+ const scoped = this.resolveScopedWords(ref.words, scope);
1246
+ if (scoped)
1247
+ return scoped;
1248
+ const id = this.resolveEntityId(ref);
1249
+ if (id !== null)
1250
+ return id === 'player' ? { kind: 'player' } : { kind: 'entity', id };
1251
+ return { kind: 'symbol', name: ref.words.join(' ') };
1252
+ }
1253
+ resolveEntityValue(ref, scope) {
1254
+ // `holds nothing` — the empty-contents idiom, not an entity lookup.
1255
+ if (ref.words.length === 1 && ref.words[0].toLowerCase() === 'nothing') {
1256
+ return { kind: 'symbol', name: 'nothing' };
1257
+ }
1258
+ const value = this.resolveRefValue(ref, scope);
1259
+ return value;
1260
+ }
1261
+ /**
1262
+ * Resolve a name to an entity ID: exact name → exact alias → unique
1263
+ * in-order word-subset match. Ambiguity and misses are errors (with
1264
+ * rename/nearest suggestions) — never a guess.
1265
+ */
1266
+ resolveEntityId(ref) {
1267
+ const lower = ref.words.join(' ').toLowerCase();
1268
+ if (PLAYER_WORDS.has(lower)) {
1269
+ const player = this.entities.find((e) => e.id === 'player');
1270
+ if (player)
1271
+ return player.id;
1272
+ }
1273
+ const exact = this.entities.filter((e) => e.nameLower === lower);
1274
+ if (exact.length === 1)
1275
+ return exact[0].id;
1276
+ const byAlias = this.entities.filter((e) => e.aka.includes(lower));
1277
+ if (byAlias.length === 1)
1278
+ return byAlias[0].id;
1279
+ if (byAlias.length > 1) {
1280
+ this.diagnostics.error('analysis.ambiguous-reference', `\`${ref.words.join(' ')}\` is ambiguous — it could be ${byAlias.map((e) => `\`${e.nameLower}\``).join(' or ')}. Use the full name, or rename an alias.`, ref.span);
1281
+ return null;
1282
+ }
1283
+ const refWords = ref.words.map((w) => w.toLowerCase());
1284
+ const subset = this.entities.filter((e) => isInOrderSubset(refWords, e.nameWords));
1285
+ if (subset.length === 1)
1286
+ return subset[0].id;
1287
+ if (subset.length > 1) {
1288
+ this.diagnostics.error('analysis.ambiguous-reference', `\`${ref.words.join(' ')}\` is ambiguous — it could be ${subset.map((e) => `\`${e.nameLower}\``).join(' or ')}. Use the full name, or rename an alias.`, ref.span);
1289
+ return null;
1290
+ }
1291
+ const candidates = [
1292
+ ...this.entities.map((e) => e.nameLower),
1293
+ ...this.entities.flatMap((e) => e.aka),
1294
+ ];
1295
+ this.diagnostics.error('analysis.unknown-entity', `No entity named \`${ref.words.join(' ')}\`${this.suggestText(lower, candidates)}.`, ref.span);
1296
+ return null;
1297
+ }
1298
+ // ---------------------------------------------------------- conditions
1299
+ resolveCondition(cond, scope) {
1300
+ switch (cond.kind) {
1301
+ case 'or':
1302
+ case 'and':
1303
+ return { kind: cond.kind, operands: cond.operands.map((o) => this.resolveCondition(o, scope)) };
1304
+ case 'not':
1305
+ return { kind: 'not', operand: this.resolveCondition(cond.operand, scope) };
1306
+ case 'chance':
1307
+ return { kind: 'chance', n: cond.n };
1308
+ case 'condition-ref': {
1309
+ if (this.conditionNames.has(cond.name)) {
1310
+ // Never-guess gate (grammar log 2026-07-11): an OPEN condition is a
1311
+ // selection over `it` — as a bare truth test it needs `it` in scope.
1312
+ if (this.openConditions.get(cond.name) && !scopeHasIt(scope)) {
1313
+ this.diagnostics.error('analysis.open-condition-truth', `\`${cond.name}\` is an open condition (it references \`it\`) — here there is no \`it\` to test. Use \`any ${cond.name}\` to test for a matching entity, \`no ${cond.name}\` to test for none, or a closed condition.`, cond.span);
1314
+ }
1315
+ return { kind: 'condition', name: cond.name };
1316
+ }
1317
+ // A story state reads as a phase test (`while after-hours`, D2).
1318
+ if (this.storyStates.includes(cond.name)) {
1319
+ return { kind: 'story-state', state: cond.name };
1320
+ }
1321
+ this.diagnostics.error('analysis.unknown-condition', `\`${cond.name}\` is not a declared condition or story state${this.suggestText(cond.name, [...this.conditionNames, ...this.storyStates])}.`, cond.span);
1322
+ return { kind: 'condition', name: cond.name };
1323
+ }
1324
+ case 'any-of':
1325
+ case 'none-of':
1326
+ // E1/E2 (ratchet 2026-07-12): existential / negated existential
1327
+ // over a named OPEN condition. Closed, story-state, and unknown
1328
+ // names are load errors — never a guess.
1329
+ this.requireOpenCondition(cond.condition, cond.span, cond.kind === 'any-of' ? 'any' : 'no');
1330
+ return { kind: cond.kind, condition: cond.condition };
1331
+ case 'predicate': {
1332
+ const subject = this.resolveValue(cond.subject, scope);
1333
+ switch (cond.predicate.kind) {
1334
+ case 'is': {
1335
+ const object = this.resolveIsObject(cond.predicate.value, subject, scope, cond.predicate.span);
1336
+ return { kind: 'predicate', pred: 'is', negated: cond.predicate.negated, subject, object };
1337
+ }
1338
+ case 'is-a':
1339
+ return {
1340
+ kind: 'predicate',
1341
+ pred: 'is-a',
1342
+ negated: cond.predicate.negated,
1343
+ subject,
1344
+ object: { kind: 'symbol', name: cond.predicate.classifier.join(' ') },
1345
+ };
1346
+ case 'is-in':
1347
+ return {
1348
+ kind: 'predicate',
1349
+ pred: 'is-in',
1350
+ negated: cond.predicate.negated,
1351
+ subject,
1352
+ object: this.resolveEntityValue(cond.predicate.place, scope),
1353
+ };
1354
+ case 'is-here': {
1355
+ // Z4 deictic: entity-valued subjects only — a literal can
1356
+ // never be "here", so reject at load rather than evaluating
1357
+ // to a silent false. (A no-LOCATION entity evaluating false
1358
+ // is a runtime semantic, not a load-time check.)
1359
+ if (subject.kind === 'literal' || subject.kind === 'symbol' || subject.kind === 'story') {
1360
+ this.diagnostics.error('analysis.here-subject', '`is here` needs an entity subject — the deictic tests whether the subject shares the player\'s location.', cond.predicate.span);
1361
+ }
1362
+ return {
1363
+ kind: 'predicate',
1364
+ pred: 'is-here',
1365
+ negated: cond.predicate.negated,
1366
+ subject,
1367
+ object: { kind: 'symbol', name: 'here' },
1368
+ };
1369
+ }
1370
+ case 'has':
1371
+ case 'holds':
1372
+ case 'wears':
1373
+ return {
1374
+ kind: 'predicate',
1375
+ pred: cond.predicate.kind,
1376
+ negated: false,
1377
+ subject,
1378
+ object: this.resolveEntityValue(cond.predicate.thing, scope),
1379
+ };
1380
+ case 'can':
1381
+ // Phase B (§2.7): resolution matches has/holds; evaluation lands
1382
+ // with the loader phases.
1383
+ return {
1384
+ kind: 'predicate',
1385
+ pred: cond.predicate.ability === 'see' ? 'can-see' : 'can-reach',
1386
+ negated: false,
1387
+ subject,
1388
+ object: this.resolveEntityValue(cond.predicate.thing, scope),
1389
+ };
1390
+ case 'is-any':
1391
+ // `<subject> must be any <name>` membership (David, 2026-07-12
1392
+ // — P3): the subject satisfies the named open condition (its
1393
+ // `it` bound to the subject at evaluation).
1394
+ this.requireOpenCondition(cond.predicate.condition, cond.predicate.span, 'any');
1395
+ return { kind: 'satisfies', subject, condition: cond.predicate.condition };
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ /**
1401
+ * E1/E2/E3 never-guess gate: `any`/`no`/`each` (and `must be any`)
1402
+ * reference a declared OPEN condition. A closed condition revives the
1403
+ * pre-ownership gate verbatim (`analysis.closed-condition-selection`);
1404
+ * story states and unknown names get their own errors.
1405
+ */
1406
+ requireOpenCondition(name, span, form) {
1407
+ if (this.openConditions.get(name))
1408
+ return;
1409
+ if (this.conditionNames.has(name)) {
1410
+ this.diagnostics.error('analysis.closed-condition-selection', `\`${name}\` is a closed condition — it never mentions \`it\`, so it doesn't describe a thing. Reference \`it\` in the condition to make it a selection.`, span);
1411
+ return;
1412
+ }
1413
+ if (this.storyStates.includes(name)) {
1414
+ this.diagnostics.error('analysis.closed-condition-selection', `\`${name}\` is a story state (a truth test), not an open condition — \`${form}\` selects entities via a condition that references \`it\`.`, span);
1415
+ return;
1416
+ }
1417
+ this.diagnostics.error('analysis.unknown-condition', `\`${name}\` is not a declared condition${this.suggestText(name, [...this.conditionNames])}.`, span);
1418
+ }
1419
+ /**
1420
+ * The object of `is` may be a state of the subject entity, a trait
1421
+ * adjective, a literal, or an entity. A bare word that is none of these
1422
+ * is the unknown-value gate (with nearest-valid suggestions).
1423
+ */
1424
+ resolveIsObject(expr, subject, scope, span) {
1425
+ if (expr.kind === 'literal')
1426
+ return this.resolveValue(expr, scope);
1427
+ const words = expr.kind === 'ref' ? expr.ref.words : expr.kind === 'bare' ? expr.words : null;
1428
+ if (words && words.length === 1) {
1429
+ const word = words[0];
1430
+ // Trait-field subjects validate against the field's own value set
1431
+ // (`if kind is snake` — one-of members; flags — true/false).
1432
+ const fieldValues = this.fieldValueSet(subject, scope);
1433
+ if (fieldValues) {
1434
+ if (fieldValues.includes(word))
1435
+ return { kind: 'symbol', name: word };
1436
+ this.diagnostics.error('analysis.unknown-value', `\`${word}\` is not a value of this field${this.suggestText(word, fieldValues)}.`, span);
1437
+ return { kind: 'symbol', name: word };
1438
+ }
1439
+ // The `each`-block binder: its state set is statically unknowable
1440
+ // (any world entity may match) — same stance as `change the match
1441
+ // to <state>`; the runtime resolves the word against the live match.
1442
+ if (subject.kind === 'match')
1443
+ return { kind: 'symbol', name: word };
1444
+ const subjectEntity = subject.kind === 'entity' ? this.byId.get(subject.id) : subject.kind === 'it' ? scope.owner : null;
1445
+ // Trait scope: `it` validates against the trait's own declared states
1446
+ // (ratchet D8) when no concrete owner entity is in scope.
1447
+ const validStates = subjectEntity?.states ?? (subject.kind === 'it' ? scope.ownStates ?? [] : []);
1448
+ if (validStates.includes(word))
1449
+ return { kind: 'symbol', name: word };
1450
+ if (catalog_1.TRAIT_ADJECTIVES.has(word))
1451
+ return { kind: 'symbol', name: word };
1452
+ // State adjectives (ratchet D1): read live from world trait state.
1453
+ if (catalog_1.STATE_ADJECTIVES.has(word))
1454
+ return { kind: 'symbol', name: word };
1455
+ const exactEntity = this.entities.find((e) => e.nameLower === word.toLowerCase() || e.aka.includes(word.toLowerCase()));
1456
+ if (exactEntity)
1457
+ return { kind: 'entity', id: exactEntity.id };
1458
+ const valid = [...validStates, ...catalog_1.TRAIT_ADJECTIVES, ...catalog_1.STATE_ADJECTIVES];
1459
+ 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
+ return { kind: 'symbol', name: word };
1461
+ }
1462
+ return this.resolveValue(expr, scope);
1463
+ }
1464
+ /** Valid comparison values for a trait-field subject, or null. */
1465
+ fieldValueSet(subject, scope) {
1466
+ if (subject.kind !== 'field' || subject.base.kind !== 'it' || !scope.fields)
1467
+ return null;
1468
+ const field = scope.fields.get(subject.field);
1469
+ if (!field)
1470
+ return null;
1471
+ if (field.type === 'one-of')
1472
+ return field.oneOf ?? [];
1473
+ return null;
1474
+ }
1475
+ // ------------------------------------------------------------- phrases
1476
+ /** Gate: every referenced phrase key must resolve in the default locale. */
1477
+ requirePhrase(key, span, owner = null) {
1478
+ const table = this.phrases.get(DEFAULT_LOCALE);
1479
+ if (table?.has(key))
1480
+ return;
1481
+ if (owner && table?.has(`${owner.id}.${key}`))
1482
+ return;
1483
+ const known = table ? [...table.keys()] : [];
1484
+ this.diagnostics.error('analysis.missing-phrase', `Phrase \`${key}\` is not declared in ${DEFAULT_LOCALE}${this.suggestText(key, known)}.`, span);
1485
+ }
1486
+ /**
1487
+ * Gate: bare single-word `{…}` markers must name a declared hatch or
1488
+ * phrase key. Formatter-chain forms (uppercase start, spaces, `:`) are
1489
+ * outside the Phase A validation slice.
1490
+ */
1491
+ checkMarkers() {
1492
+ for (const [, table] of this.phrases) {
1493
+ 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
+ }
1514
+ }
1515
+ }
1516
+ }
1517
+ /**
1518
+ * Z2 (ADR-211): validate `{key}` phrase markers in ROOM description prose
1519
+ * — the sites the loader compiles to `{snippet:key}` + `RoomTrait.snippets`.
1520
+ * Never-guess diagnostics live here: a separator-led variant is a load
1521
+ * error with the bare-fragment fix-it (AC-3), a clause-site fragment ending
1522
+ * in a sentence terminator is a lint warning, and a `verbatim` phrase at a
1523
+ * description marker is a load error. `nothing` is the explicit empty
1524
+ * variant and is exempt. The rewrite itself is the loader's, atomically
1525
+ * with the snippet-map population.
1526
+ */
1527
+ checkDescriptionMarkers() {
1528
+ const table = this.phrases.get(DEFAULT_LOCALE);
1529
+ if (!table)
1530
+ return;
1531
+ const reportedBare = new Set();
1532
+ for (const e of this.entities) {
1533
+ const isRoom = e.decl.compositions.some((c) => c.article && c.words.join(' ').toLowerCase() === 'room');
1534
+ if (!isRoom)
1535
+ continue;
1536
+ for (const key of [`${e.id}.description`, `${e.id}.initial-description`]) {
1537
+ const desc = table.get(key);
1538
+ if (!desc)
1539
+ continue;
1540
+ for (const site of descriptionMarkerSites(desc.variants[0]?.text ?? '')) {
1541
+ if (site.marker === 'br' || this.hatchNames.has(site.marker))
1542
+ continue;
1543
+ const target = table.get(site.marker);
1544
+ if (!target)
1545
+ continue; // unbound → checkMarkers' analysis.unbound-marker
1546
+ if (target.verbatim) {
1547
+ this.diagnostics.error('analysis.verbatim-marker', `\`{${site.marker}}\` in \`${key}\` names a \`verbatim\` phrase — verbatim text cannot splice at a description marker.`, desc.span);
1548
+ continue;
1549
+ }
1550
+ for (const variant of target.variants) {
1551
+ if (variant.text === 'nothing')
1552
+ continue; // explicit empty variant (Z2)
1553
+ if (SEPARATOR_LED.test(variant.text)) {
1554
+ if (!reportedBare.has(site.marker)) {
1555
+ reportedBare.add(site.marker);
1556
+ this.diagnostics.error('analysis.fragment-not-bare', `A variant of \`${site.marker}\` begins with punctuation/whitespace — write the fragment bare; the separator is platform-owned (ADR-211).`, target.span);
1557
+ }
1558
+ }
1559
+ else if (site.mode === 'clause' && /[.?!]$/.test(variant.text.trimEnd())) {
1560
+ this.diagnostics.warning('analysis.fragment-terminator', `\`{${site.marker}}\` sits mid-sentence in \`${key}\`, but a variant ends with a sentence terminator — the clause join (\`, \`) will read oddly.`, target.span);
1561
+ }
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+ // ---------------------------------------------------------- suggestions
1568
+ /** `— did you mean \`x\`?` when a near match exists, else empty. */
1569
+ suggestText(input, candidates) {
1570
+ const best = nearest(input, candidates);
1571
+ return best ? ` — did you mean \`${best}\`?` : '';
1572
+ }
1573
+ }
1574
+ /** Separator-shaped leading characters a bare fragment must not carry (mirrors the engine's ADR-211 gate). */
1575
+ const SEPARATOR_LED = /^[\s,.;:?!]/;
1576
+ /**
1577
+ * Scan description prose for `{key}` marker sites and classify each per the
1578
+ * ADR-211 join rule: mode comes from the nearest preceding non-marker,
1579
+ * non-whitespace character (adjacent markers are transparent); `.?!;:` ⇒
1580
+ * sentence, start-of-text / paragraph edge ⇒ boundary, else clause.
1581
+ */
1582
+ function descriptionMarkerSites(text) {
1583
+ const sites = [];
1584
+ for (const match of text.matchAll(/\{([a-z][a-z0-9-]*)\}/g)) {
1585
+ let i = (match.index ?? 0) - 1;
1586
+ let mode = null;
1587
+ while (i >= 0) {
1588
+ const ch = text[i];
1589
+ if (ch === '}') {
1590
+ const open = text.lastIndexOf('{', i);
1591
+ if (open >= 0 && /^\{[a-z][a-z0-9-]*\}$/.test(text.slice(open, i + 1))) {
1592
+ i = open - 1; // adjacent marker: transparent — keep scanning left
1593
+ continue;
1594
+ }
1595
+ mode = 'clause';
1596
+ break;
1597
+ }
1598
+ if (ch === '\n' && text[i - 1] === '\n') {
1599
+ mode = 'boundary';
1600
+ break;
1601
+ }
1602
+ if (/\s/.test(ch)) {
1603
+ i--;
1604
+ continue;
1605
+ }
1606
+ mode = '.?!;:'.includes(ch) ? 'sentence' : 'clause';
1607
+ break;
1608
+ }
1609
+ sites.push({ marker: match[1], mode: mode ?? 'boundary' });
1610
+ }
1611
+ return sites;
1612
+ }
1613
+ /** True when `needle` appears in `haystack` in order (not necessarily adjacent). */
1614
+ function isInOrderSubset(needle, haystack) {
1615
+ if (needle.length === 0)
1616
+ return false;
1617
+ let i = 0;
1618
+ for (const word of haystack) {
1619
+ if (word === needle[i])
1620
+ i++;
1621
+ if (i === needle.length)
1622
+ return true;
1623
+ }
1624
+ return false;
1625
+ }
1626
+ /** Nearest candidate within an edit distance budget, or null. */
1627
+ function nearest(input, candidates) {
1628
+ let best = null;
1629
+ let bestDist = Math.max(2, Math.floor(input.length / 3)) + 1;
1630
+ for (const c of candidates) {
1631
+ const d = levenshtein(input.toLowerCase(), c.toLowerCase());
1632
+ if (d < bestDist) {
1633
+ bestDist = d;
1634
+ best = c;
1635
+ }
1636
+ }
1637
+ return best;
1638
+ }
1639
+ function levenshtein(a, b) {
1640
+ const m = a.length;
1641
+ const n = b.length;
1642
+ if (m === 0)
1643
+ return n;
1644
+ if (n === 0)
1645
+ return m;
1646
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
1647
+ for (let i = 1; i <= m; i++) {
1648
+ const row = [i];
1649
+ for (let j = 1; j <= n; j++) {
1650
+ row.push(Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
1651
+ }
1652
+ prev = row;
1653
+ }
1654
+ return prev[n];
1655
+ }
1656
+ //# sourceMappingURL=analyzer.js.map