@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.d.ts +31 -0
- package/analyzer.d.ts.map +1 -0
- package/analyzer.js +1656 -0
- package/analyzer.js.map +1 -0
- package/ast.d.ts +684 -0
- package/ast.d.ts.map +1 -0
- package/ast.js +3 -0
- package/ast.js.map +1 -0
- package/catalog.d.ts +43 -0
- package/catalog.d.ts.map +1 -0
- package/catalog.js +77 -0
- package/catalog.js.map +1 -0
- package/diagnostics.d.ts +38 -0
- package/diagnostics.d.ts.map +1 -0
- package/diagnostics.js +29 -0
- package/diagnostics.js.map +1 -0
- package/index.d.ts +39 -0
- package/index.d.ts.map +1 -0
- package/index.js +77 -0
- package/index.js.map +1 -0
- package/ir.d.ts +511 -0
- package/ir.d.ts.map +1 -0
- package/ir.js +6 -0
- package/ir.js.map +1 -0
- package/lexer.d.ts +49 -0
- package/lexer.d.ts.map +1 -0
- package/lexer.js +88 -0
- package/lexer.js.map +1 -0
- package/package.json +40 -0
- package/parser.d.ts +29 -0
- package/parser.d.ts.map +1 -0
- package/parser.js +2303 -0
- package/parser.js.map +1 -0
- package/span.d.ts +30 -0
- package/span.d.ts.map +1 -0
- package/span.js +34 -0
- package/span.js.map +1 -0
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
|