@plurnk/plurnk-contracts 1.16.4 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +13 -29
  2. package/SPEC.md +605 -518
  3. package/dist/conformance/agui-v1.json +3 -3
  4. package/dist/schema/CapabilityProjection.json +2 -4
  5. package/dist/schema/ClientStatement.json +5 -52
  6. package/dist/schema/FunctionalityDefinitionState.json +2 -2
  7. package/dist/schema/LineMarker.json +1 -1
  8. package/dist/schema/LoopPolicy.json +2 -3
  9. package/dist/schema/MatcherBody.json +6 -6
  10. package/dist/schema/McpServerDefinition.json +17 -0
  11. package/dist/schema/ModelCatalogPage.json +10 -1
  12. package/dist/schema/ModelRoute.json +9 -0
  13. package/dist/schema/Notice.json +1 -1
  14. package/dist/schema/ParsedPath.json +3 -2
  15. package/dist/schema/Plan.json +9 -5
  16. package/dist/schema/PlurnkStatement.json +86 -134
  17. package/dist/schema/ProposalProjection.json +2 -1
  18. package/dist/schema/ResourceSelection.json +51 -27
  19. package/dist/schema/SkillDefinition.json +3 -3
  20. package/dist/src/AcpPlanValue.d.ts +0 -1
  21. package/dist/src/AcpPlanValue.d.ts.map +1 -1
  22. package/dist/src/AcpPlanValue.js +14 -13
  23. package/dist/src/AcpPlanValue.js.map +1 -1
  24. package/dist/src/ApplicationPort.d.ts +16 -14
  25. package/dist/src/ApplicationPort.d.ts.map +1 -1
  26. package/dist/src/AstBuilder.d.ts +3 -2
  27. package/dist/src/AstBuilder.d.ts.map +1 -1
  28. package/dist/src/AstBuilder.js +340 -187
  29. package/dist/src/AstBuilder.js.map +1 -1
  30. package/dist/src/JsonDocument.d.ts +2 -0
  31. package/dist/src/JsonDocument.d.ts.map +1 -0
  32. package/dist/src/JsonDocument.js +14 -0
  33. package/dist/src/JsonDocument.js.map +1 -0
  34. package/dist/src/LoopLifecycle.d.ts +3 -0
  35. package/dist/src/LoopLifecycle.d.ts.map +1 -0
  36. package/dist/src/LoopLifecycle.js +14 -0
  37. package/dist/src/LoopLifecycle.js.map +1 -0
  38. package/dist/src/PlanValue.d.ts +1 -1
  39. package/dist/src/PlanValue.d.ts.map +1 -1
  40. package/dist/src/PlanValue.js +10 -6
  41. package/dist/src/PlanValue.js.map +1 -1
  42. package/dist/src/PlurnkErrorStrategy.d.ts.map +1 -1
  43. package/dist/src/PlurnkErrorStrategy.js +58 -139
  44. package/dist/src/PlurnkErrorStrategy.js.map +1 -1
  45. package/dist/src/PlurnkParseError.d.ts +3 -1
  46. package/dist/src/PlurnkParseError.d.ts.map +1 -1
  47. package/dist/src/PlurnkParseError.js +4 -1
  48. package/dist/src/PlurnkParseError.js.map +1 -1
  49. package/dist/src/PlurnkParser.d.ts +9 -5
  50. package/dist/src/PlurnkParser.d.ts.map +1 -1
  51. package/dist/src/PlurnkParser.js +175 -180
  52. package/dist/src/PlurnkParser.js.map +1 -1
  53. package/dist/src/RecordingListener.d.ts +1 -1
  54. package/dist/src/RecordingListener.d.ts.map +1 -1
  55. package/dist/src/RecordingListener.js +24 -6
  56. package/dist/src/RecordingListener.js.map +1 -1
  57. package/dist/src/TurnDisposition.d.ts +11 -0
  58. package/dist/src/TurnDisposition.d.ts.map +1 -0
  59. package/dist/src/TurnDisposition.js +32 -0
  60. package/dist/src/TurnDisposition.js.map +1 -0
  61. package/dist/src/Validator.js +1 -1
  62. package/dist/src/Validator.js.map +1 -1
  63. package/dist/src/generated/plurnkLexer.d.ts +95 -106
  64. package/dist/src/generated/plurnkLexer.d.ts.map +1 -1
  65. package/dist/src/generated/plurnkLexer.js +674 -828
  66. package/dist/src/generated/plurnkLexer.js.map +1 -1
  67. package/dist/src/generated/plurnkParser.d.ts +111 -152
  68. package/dist/src/generated/plurnkParser.d.ts.map +1 -1
  69. package/dist/src/generated/plurnkParser.js +756 -1082
  70. package/dist/src/generated/plurnkParser.js.map +1 -1
  71. package/dist/src/generated/plurnkParserVisitor.d.ts +15 -36
  72. package/dist/src/generated/plurnkParserVisitor.d.ts.map +1 -1
  73. package/dist/src/generated/plurnkParserVisitor.js +12 -30
  74. package/dist/src/generated/plurnkParserVisitor.js.map +1 -1
  75. package/dist/src/index.d.ts +5 -1
  76. package/dist/src/index.d.ts.map +1 -1
  77. package/dist/src/index.js +4 -1
  78. package/dist/src/index.js.map +1 -1
  79. package/dist/src/types.d.ts +3 -7
  80. package/dist/src/types.d.ts.map +1 -1
  81. package/dist/src/types.generated.d.ts +147 -80
  82. package/dist/src/types.generated.d.ts.map +1 -1
  83. package/dist/src/types.js +3 -3
  84. package/dist/src/types.js.map +1 -1
  85. package/package.json +4 -8
  86. package/plurnk.md +103 -119
  87. package/dist/plurnk.gemma.gbnf +0 -141
  88. package/dist/plurnk.qwen.gbnf +0 -130
@@ -2,13 +2,14 @@
2
2
  import { ParserRuleContext, TerminalNode } from "antlr4ng";
3
3
  import * as xpath from "xpath";
4
4
  import { JSONPathEnvironment } from "json-p3/dist/json-p3.esm.js";
5
- import { BodyContext, LineMarkerContext, MetadataContext, MidSendContext, PlanStatementContext, SendStatementContext, TargetContext, } from "./generated/plurnkParser.js";
5
+ import { BodyContext, LineMarkerContext, MetadataContext, DispositionStatementContext, SendStatementContext, TargetContext, } from "./generated/plurnkParser.js";
6
6
  import { plurnkLexer } from "./generated/plurnkLexer.js";
7
7
  import PlurnkParseError from "./PlurnkParseError.js";
8
8
  import PathSyntax from "./PathSyntax.js";
9
9
  import PlanValue from "./PlanValue.js";
10
+ import TurnDisposition from "./TurnDisposition.js";
10
11
  export default class AstBuilder {
11
- // {§misplaced-annotation-advisory} — advisories raised while building one statement; the
12
+ // {§misplaced-aside-advisory} — advisories raised while building one statement; the
12
13
  // parser drains them right after the statement so the model sees WHAT it did on the first try.
13
14
  static #advisories = [];
14
15
  static takeAdvisories() {
@@ -17,32 +18,165 @@ export default class AstBuilder {
17
18
  return taken;
18
19
  }
19
20
  // A body that is solely an HTML comment can never be a matcher. Preserve it
20
- // as the operation annotation and report only that deterministic normalization.
21
- static #annotationBody(op, delimiter, annotation, raw, position) {
21
+ // as the operation aside and report only that deterministic normalization.
22
+ static #asideBody(op, aside, raw, position) {
22
23
  if (raw === null)
23
- return { annotation, raw };
24
+ return { aside, raw };
24
25
  const comment = /^\s*<!--([\s\S]*?)-->\s*$/u.exec(raw);
25
26
  if (comment === null)
26
- return { annotation, raw };
27
- AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `The ${op} body contained only an HTML comment; it was applied as the operation annotation.`, "warning"));
28
- return { annotation: annotation ?? (comment[1] ?? "").trim(), raw: null };
27
+ return { aside, raw };
28
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `The ${op} body contained only an HTML comment; it was applied as the operation aside.`, "warning"));
29
+ return { aside: aside ?? (comment[1] ?? "").trim(), raw: null };
30
+ }
31
+ // {§matcher-option} — `pattern` is the language's key inside `[metadata]`: lifted into the
32
+ // statement's `matcher`, classified exactly as a body matcher was ({§matcher-prefix-claims}).
33
+ // A block that carries only `pattern` leaves no metadata for the owner; beside other keys the
34
+ // block stays for the owner, whose reader skips the reserved key. The block's shape stays the
35
+ // owner's business ({§scheme-metadata-modifier}): a second block or malformed JSON lifts
36
+ // nothing and reaches the owner's 400 untouched; only a present `pattern` that is not a
37
+ // string, or a malformed matcher, is the language's own positioned diagnostic.
38
+ // {§naked-pattern} — one line of matcher text, its trailing aside split back out. A sigil
39
+ // (`/`, `//`, `$`, `~`, `&`, `^`) is a matcher wherever it stands; a sigil-less glob or literal
40
+ // is one only on the heading line of FIND, READ or KILL, where the text can mean nothing else.
41
+ // {§trailing-slots} — the slots after the matcher peel off the right end of the heading text,
42
+ // aside, scope and option block in any order, until what remains is the matcher.
43
+ static #bareMatcher(raw, op, inline, position, carried = { scope: false, metadata: false }) {
44
+ if (raw === null)
45
+ return null;
46
+ let text = raw.trim();
47
+ if (text === "" || text.includes("\n"))
48
+ return null;
49
+ let aside = null;
50
+ let scope = null;
51
+ let metadata = null;
52
+ // A slot the heading already carries is not peeled: a second one is trailing text.
53
+ let scopeFree = !carried.scope;
54
+ let metadataFree = !carried.metadata;
55
+ const scopeTail = op === "FIND" ? AstBuilder.#TAIL_POSITIONS : AstBuilder.#TAIL_TEXT_SCOPE;
56
+ for (;;) {
57
+ const trailingAside = /\s*<!--([\s\S]*?)-->\s*$/u.exec(text);
58
+ if (trailingAside !== null && aside === null) {
59
+ aside = (trailingAside[1] ?? "").trim();
60
+ text = text.slice(0, trailingAside.index).trim();
61
+ continue;
62
+ }
63
+ const trailingScope = scopeTail.exec(text);
64
+ if (trailingScope !== null && scopeFree && trailingScope.index > 0) {
65
+ scope = trailingScope[1];
66
+ scopeFree = false;
67
+ text = text.slice(0, trailingScope.index).trim();
68
+ AstBuilder.#adviseTrailing(position, `\`${scope}\` after the pattern was read as the scope; the scope goes before the pattern.`);
69
+ continue;
70
+ }
71
+ const trailingBlock = /\s*\[(\{[\s\S]*\})\]\s*$/u.exec(text);
72
+ if (trailingBlock !== null && metadataFree && trailingBlock.index > 0 && AstBuilder.#isJsonArrayOfObjects(trailingBlock[1])) {
73
+ metadata = trailingBlock[1];
74
+ metadataFree = false;
75
+ text = text.slice(0, trailingBlock.index).trim();
76
+ AstBuilder.#adviseTrailing(position, `\`[${metadata}]\` after the pattern was read as the option block; options go before the pattern.`);
77
+ continue;
78
+ }
79
+ break;
80
+ }
81
+ if (text === "")
82
+ return null;
83
+ if (AstBuilder.#SIGIL.test(text))
84
+ return { text, aside, scope, metadata };
85
+ if (!inline || (op !== "FIND" && op !== "READ" && op !== "KILL"))
86
+ return null;
87
+ return { text, aside, scope, metadata };
88
+ }
89
+ static #adviseTrailing(position, message) {
90
+ if (position === undefined)
91
+ return;
92
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", message, "warning"));
93
+ }
94
+ static #isJsonArrayOfObjects(inner) {
95
+ try {
96
+ const parsed = JSON.parse(`[${inner}]`);
97
+ return Array.isArray(parsed) && parsed.every((element) => typeof element === "object" && element !== null && !Array.isArray(element));
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ static #SIGIL = /^(\/|\$|~|&|\^)/u;
104
+ // The scope shapes the lexer admits, matched at the right end of the heading text.
105
+ static #TAIL_POSITIONS = /\s*(<-?[0-9]+(?:\.[0-9]+)?(?:(?:,\s?|-)-?[0-9]+(?:\.[0-9]+)?)*>)\s*$/u;
106
+ static #TAIL_TEXT_SCOPE = /\s*(<(?:-?[0-9]+(?:\.[0-9]+)?|@[0-9A-Za-z]{5}(?:[: ][1-9][0-9]*)?|@[0-9]{1,4})(?:(?:,\s?|-)(?:-?[0-9]+(?:\.[0-9]+)?|@[0-9A-Za-z]{5}(?:[: ][1-9][0-9]*)?|@[0-9]{1,4}))*>)\s*$/u;
107
+ // The body text that opened on the heading line itself, split from the lines beneath it.
108
+ static #splitInlineBody(ctx, position) {
109
+ const text = AstBuilder.#bodyTextOf(ctx);
110
+ if (text === null)
111
+ return { inline: null, below: null };
112
+ const body = AstBuilder.#findFirst(ctx, BodyContext);
113
+ if (body?.start === null || body?.start === undefined || body.start.line !== position.line)
114
+ return { inline: null, below: text };
115
+ const eol = text.search(/\r?\n/u);
116
+ if (eol === -1)
117
+ return { inline: text, below: null };
118
+ const below = text.slice(eol).replace(/^\r?\n/u, "");
119
+ return { inline: text.slice(0, eol), below: below === "" ? null : below };
120
+ }
121
+ static #liftMatcher(op, metadata, position, raw = null, inline = false, carriedScope = false) {
122
+ if (metadata === null || metadata.length !== 1) {
123
+ const bare = AstBuilder.#bareMatcher(raw, op, inline, position, { scope: carriedScope, metadata: metadata !== null });
124
+ return bare === null
125
+ ? { matcher: null, metadata, aside: null, scope: null }
126
+ : {
127
+ matcher: AstBuilder.#parseMatcherBody(bare.text, position),
128
+ metadata: metadata ?? (bare.metadata === null ? null : [bare.metadata]),
129
+ aside: bare.aside,
130
+ scope: bare.scope,
131
+ };
132
+ }
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(`[${metadata[0]}]`);
136
+ }
137
+ catch (cause) {
138
+ if (!(cause instanceof SyntaxError))
139
+ throw cause;
140
+ return { matcher: null, metadata, aside: null, scope: null };
141
+ }
142
+ const elements = parsed;
143
+ if (elements.some((element) => typeof element !== "object" || element === null || Array.isArray(element))) {
144
+ return { matcher: null, metadata, aside: null, scope: null };
145
+ }
146
+ const options = Object.assign({}, ...elements);
147
+ if (!Object.hasOwn(options, "pattern"))
148
+ return { matcher: null, metadata, aside: null, scope: null };
149
+ const pattern = options.pattern;
150
+ if (typeof pattern !== "string") {
151
+ throw new PlurnkParseError(position.line, position.column, "visitor", `${op} "pattern" must be a string matcher, e.g. [{"pattern": "/needle/i"}].`);
152
+ }
153
+ const matcher = AstBuilder.#parseMatcherBody(pattern, position);
154
+ const others = Object.keys(options).filter((key) => key !== "pattern");
155
+ return { matcher, metadata: others.length === 0 ? null : metadata, aside: null, scope: null };
156
+ }
157
+ // {§matcher-option} — a text or log operation's body is never a matcher; it is ignored with one
158
+ // advisory naming the option form, and the operation still runs (operator, 2026-09-12: warn, never
159
+ // strike, over a body the model was taught not to write).
160
+ static #adviseBody(op, raw, position) {
161
+ if (raw === null || raw.trim() === "")
162
+ return;
163
+ if (AstBuilder.#bareMatcher(raw, op, false) !== null)
164
+ return;
165
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `${op} takes no body; the body was ignored. A pattern belongs on the opening fence line after the path.`, "warning"));
29
166
  }
30
167
  static #SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
31
168
  // Compile-only RFC 9535 admission using the runtime's JSONPath engine. {§matcher-prefix-claims}
32
169
  static #JSONPATH = new JSONPathEnvironment();
33
170
  static #GRAPH_MATCHER = /^&[<>]?[^\s<>]\S*$/u;
34
171
  static build(ctx) {
35
- // The strict turn root attaches the leading PLAN and the terminal SEND as direct
36
- // children (not wrapped in `statement`), so dispatch those by type first.
37
- if (ctx instanceof PlanStatementContext)
38
- return AstBuilder.#buildPlan(ctx);
172
+ // Disposition and SEND contexts can arrive without a statement wrapper.
173
+ if (ctx instanceof DispositionStatementContext)
174
+ return AstBuilder.#buildDisposition(ctx);
39
175
  if (ctx instanceof SendStatementContext)
40
176
  return AstBuilder.#buildSend(ctx);
41
- if (ctx instanceof MidSendContext)
42
- return AstBuilder.#buildMidSend(ctx);
43
- const midSend = ctx.midSend();
44
- if (midSend)
45
- return AstBuilder.#buildMidSend(midSend);
177
+ const send = ctx.sendStatement();
178
+ if (send)
179
+ return AstBuilder.#buildSend(send);
46
180
  const find = ctx.findStatement();
47
181
  if (find)
48
182
  return AstBuilder.#buildFind(find);
@@ -73,32 +207,32 @@ export default class AstBuilder {
73
207
  const kill = ctx.killStatement();
74
208
  if (kill)
75
209
  return AstBuilder.#buildKill(kill);
76
- // `midStatement` has no planStatement alternative (PLAN is never a mid-op); only the
77
- // full `statement` rule does.
78
- if ("planStatement" in ctx) {
79
- const plan = ctx.planStatement();
80
- if (plan)
81
- return AstBuilder.#buildPlan(plan);
82
- const send = ctx.sendStatement();
83
- if (send)
84
- return AstBuilder.#buildSend(send);
210
+ if ("dispositionStatement" in ctx) {
211
+ const disposition = ctx.dispositionStatement();
212
+ if (disposition)
213
+ return AstBuilder.#buildDisposition(disposition);
85
214
  }
86
215
  throw new Error("statement context has no recognized alternative");
87
216
  }
88
217
  static #buildFind(ctx) {
89
- const positionForBody = AstBuilder.#positionOf(ctx);
90
- const bodied = AstBuilder.#annotationBody("FIND", AstBuilder.#splitDelimiter(ctx.OPEN_FIND().getText(), "FIND"), AstBuilder.#annotationOf(ctx), AstBuilder.#bodyTextOf(ctx), positionForBody);
91
- return AstBuilder.#buildFindFrom(ctx, bodied.annotation, bodied.raw);
218
+ const position = AstBuilder.#positionOf(ctx);
219
+ const split = AstBuilder.#splitInlineBody(ctx, position);
220
+ const bodied = AstBuilder.#asideBody("FIND", AstBuilder.#asideOf(ctx), split.below, position);
221
+ return AstBuilder.#buildFindFrom(ctx, bodied.aside, split.inline, bodied.raw);
92
222
  }
93
- static #buildFindFrom(ctx, annotation, raw) {
223
+ static #buildFindFrom(ctx, aside, inline, below) {
94
224
  const position = AstBuilder.#positionOf(ctx);
95
225
  const slots = AstBuilder.#extractSlots(ctx.slotModifiers(), position);
226
+ AstBuilder.#adviseBody("FIND", below, position);
227
+ const lifted = AstBuilder.#liftMatcher("FIND", slots.metadata, position, inline ?? below, inline !== null, slots.lineMarker !== null);
96
228
  return {
97
229
  op: "FIND",
98
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_FIND().getText(), "FIND"),
99
- annotation,
230
+ aside: aside ?? lifted.aside,
100
231
  ...slots,
101
- body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
232
+ lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseLineMarker(lifted.scope)),
233
+ metadata: lifted.metadata,
234
+ matcher: lifted.matcher,
235
+ body: null,
102
236
  position,
103
237
  };
104
238
  }
@@ -113,34 +247,17 @@ export default class AstBuilder {
113
247
  const look = ctx.lookStatement();
114
248
  if (look)
115
249
  return AstBuilder.#buildLook(look);
116
- const buff = ctx.buffStatement();
117
- if (buff)
118
- return AstBuilder.#buildBuff(buff);
119
250
  throw new Error("clientStatement context has no recognized alternative");
120
251
  }
121
- // LOOK / BUFF are client-tier matcher observations. They share the tag slots
122
- // and parse matcher bodies directly for their client-owned lifecycles.
252
+ // LOOK is the client-tier matcher observation. It shares the tag slots and parses
253
+ // its matcher body directly for its client-owned lifecycle.
123
254
  static #buildLook(ctx) {
124
255
  const position = AstBuilder.#positionOf(ctx);
125
256
  const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
126
257
  const raw = AstBuilder.#bodyTextOf(ctx);
127
258
  return {
128
259
  op: "LOOK",
129
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_LOOK().getText(), "LOOK"),
130
- annotation: AstBuilder.#annotationOf(ctx),
131
- ...slots,
132
- body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
133
- position,
134
- };
135
- }
136
- static #buildBuff(ctx) {
137
- const position = AstBuilder.#positionOf(ctx);
138
- const slots = AstBuilder.#extractSlots(ctx.slotModifiers(), position);
139
- const raw = AstBuilder.#bodyTextOf(ctx);
140
- return {
141
- op: "BUFF",
142
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_BUFF().getText(), "BUFF"),
143
- annotation: AstBuilder.#annotationOf(ctx),
260
+ aside: AstBuilder.#asideOf(ctx),
144
261
  ...slots,
145
262
  body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
146
263
  position,
@@ -149,33 +266,20 @@ export default class AstBuilder {
149
266
  static #buildRead(ctx) {
150
267
  const position = AstBuilder.#positionOf(ctx);
151
268
  const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
152
- const delimiter = AstBuilder.#splitDelimiter(ctx.OPEN_READ().getText(), "READ");
153
- const bodied = AstBuilder.#annotationBody("READ", delimiter, AstBuilder.#annotationOf(ctx), AstBuilder.#bodyTextOf(ctx), position);
154
- const annotation = bodied.annotation;
155
- const raw = bodied.raw;
156
- const targetPath = slots.target?.kind === "url"
157
- ? slots.target.pathname
158
- : slots.target?.raw;
159
- const hasMatcher = raw !== null && raw.trim() !== "";
160
- if (hasMatcher || (targetPath !== undefined && PathSyntax.hasGlob(targetPath))) {
161
- if (slots.lineMarker?.marks.some((mark) => typeof mark === "string") === true) {
162
- throw new PlurnkParseError(position.line, position.column, "visitor", "line anchors require an exact READ target; FIND result positions are numeric");
163
- }
164
- const findSlots = slots;
165
- return {
166
- op: "FIND",
167
- delimiter,
168
- annotation,
169
- ...findSlots,
170
- body: hasMatcher ? AstBuilder.#parseMatcherBody(raw, position) : null,
171
- position,
172
- };
173
- }
269
+ const split = AstBuilder.#splitInlineBody(ctx, position);
270
+ const bodied = AstBuilder.#asideBody("READ", AstBuilder.#asideOf(ctx), split.below, position);
271
+ AstBuilder.#adviseBody("READ", bodied.raw, position);
272
+ const lifted = AstBuilder.#liftMatcher("READ", slots.metadata, position, split.inline ?? bodied.raw, split.inline !== null, slots.lineMarker !== null);
273
+ const aside = bodied.aside ?? lifted.aside;
274
+ // {§read-find-normalization} — a READ is never rewritten: a glob target is the runtime's
275
+ // fan-out over every matching path, with or without a matcher (core {§read-fan-out}).
174
276
  return {
175
277
  op: "READ",
176
- delimiter,
177
- annotation,
278
+ aside,
178
279
  ...slots,
280
+ lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
281
+ metadata: lifted.metadata,
282
+ matcher: lifted.matcher,
179
283
  body: null,
180
284
  position,
181
285
  };
@@ -183,12 +287,18 @@ export default class AstBuilder {
183
287
  static #buildEdit(ctx) {
184
288
  const position = AstBuilder.#positionOf(ctx);
185
289
  const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
290
+ // {§naked-pattern} — a sigil on the heading line is the matcher; the lines beneath are the
291
+ // replacement (none deletes each match). Any other heading-line text is the body it always was.
292
+ const split = AstBuilder.#splitInlineBody(ctx, position);
293
+ const lifted = AstBuilder.#liftMatcher("EDIT", slots.metadata, position, split.inline, true, slots.lineMarker !== null);
186
294
  return {
187
295
  op: "EDIT",
188
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_EDIT().getText(), "EDIT"),
189
- annotation: AstBuilder.#annotationOf(ctx),
296
+ aside: AstBuilder.#asideOf(ctx) ?? lifted.aside,
190
297
  ...slots,
191
- body: AstBuilder.#bodyTextOf(ctx),
298
+ lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
299
+ metadata: lifted.metadata,
300
+ matcher: lifted.matcher,
301
+ body: lifted.matcher === null || split.inline === null ? AstBuilder.#bodyTextOf(ctx) : split.below,
192
302
  position,
193
303
  };
194
304
  }
@@ -200,8 +310,7 @@ export default class AstBuilder {
200
310
  throw new Error("COPY grammar did not produce two resource selections");
201
311
  return {
202
312
  op: "COPY",
203
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_COPY().getText(), "COPY"),
204
- annotation: AstBuilder.#annotationOf(ctx),
313
+ aside: AstBuilder.#asideOf(ctx),
205
314
  source: AstBuilder.#resourceSelectionFromCtx(selections[0], position),
206
315
  destination: AstBuilder.#resourceSelectionFromCtx(selections[1], position),
207
316
  position,
@@ -215,128 +324,96 @@ export default class AstBuilder {
215
324
  throw new Error("MOVE grammar did not produce two resource selections");
216
325
  return {
217
326
  op: "MOVE",
218
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_MOVE().getText(), "MOVE"),
219
- annotation: AstBuilder.#annotationOf(ctx),
327
+ aside: AstBuilder.#asideOf(ctx),
220
328
  source: AstBuilder.#resourceSelectionFromCtx(selections[0], position),
221
329
  destination: AstBuilder.#resourceSelectionFromCtx(selections[1], position),
222
330
  position,
223
331
  };
224
332
  }
225
- // {§send-label} — a disposition label makes the SEND terminal and names no recipient.
226
- static #SEND_LABELS = Object.freeze({ NEXT: 102, WAIT: 202, TERM: 200, FAIL: 499 });
227
- static #buildSend(ctx) {
333
+ static #buildDisposition(ctx) {
228
334
  const position = AstBuilder.#positionOf(ctx);
229
- const label = ctx.SEND_LABEL().getText().slice(1, -1);
230
- const status = AstBuilder.#SEND_LABELS[label];
231
- if (status === undefined)
232
- throw new Error(`the lexer admitted an unknown SEND label: ${label}`);
233
- const raw = AstBuilder.#bodyTextOf(ctx);
335
+ const op = (ctx.start?.text ?? "").replace(/^`+[0-9]*/, "");
336
+ if (!TurnDisposition.isOp(op))
337
+ throw new Error(`Unknown disposition operation: ${op}`);
338
+ // {§one-line-turn} — an inventory written as a block on the heading line is the body when
339
+ // nothing sits beneath the heading, with one advisory naming where it belongs.
340
+ const below = AstBuilder.#bodyTextOf(ctx);
341
+ const inline = ctx.metadata()?.getText() ?? null;
342
+ if (inline !== null && (below === null || below.trim() === "")) {
343
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `${op}'s inventory was read from the heading line; it belongs in the body.`, "warning"));
344
+ }
345
+ const raw = below !== null && below.trim() !== "" ? below : inline;
234
346
  return {
235
- op: "SEND",
236
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_SEND().getText(), "SEND"),
237
- annotation: AstBuilder.#annotationOf(ctx),
238
- status,
347
+ op,
348
+ aside: AstBuilder.#asideOf(ctx),
239
349
  target: null,
240
350
  metadata: null,
241
- // A WAIT keeps its scope ({§send-wait-scope}); the dispatcher owns what it accepts.
242
351
  lineMarker: AstBuilder.#lineMarkerFromCtx(ctx.lineMarker()),
243
- body: raw !== null ? AstBuilder.#parseSendBody(raw) : null,
352
+ body: PlanValue.admit(raw ?? "", (message) => AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "visitor", message, "warning"))),
244
353
  position,
245
354
  };
246
355
  }
247
356
  // A mid-turn SEND is a message to its recipient path, or to the user when it names none.
248
- static #buildMidSend(ctx) {
357
+ static #buildSend(ctx) {
249
358
  const position = AstBuilder.#positionOf(ctx);
250
- const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
359
+ const slots = AstBuilder.#extractSlots(ctx.resourceSelection(), position);
251
360
  const raw = AstBuilder.#bodyTextOf(ctx);
252
361
  return {
253
362
  op: "SEND",
254
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_SEND().getText(), "SEND"),
255
- annotation: AstBuilder.#annotationOf(ctx),
256
- status: null,
257
- target: slots.target,
258
- metadata: slots.metadata,
259
- lineMarker: null,
363
+ aside: AstBuilder.#asideOf(ctx),
364
+ ...slots,
260
365
  body: raw !== null ? AstBuilder.#parseSendBody(raw) : null,
261
366
  position,
262
367
  };
263
368
  }
264
369
  static #buildExec(ctx) {
265
370
  const position = AstBuilder.#positionOf(ctx);
266
- const slots = AstBuilder.#extractExecSlots(ctx.execModifiers(), position);
371
+ const slots = AstBuilder.#extractExecSlots(ctx.execModifiers(), position, AstBuilder.#executorOf(ctx) ?? "sh");
267
372
  return {
268
373
  op: "EXEC",
269
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_EXEC().getText(), "EXEC"),
270
- annotation: AstBuilder.#annotationOf(ctx),
271
- executor: AstBuilder.#executorOf(ctx, position),
374
+ aside: AstBuilder.#asideOf(ctx),
375
+ executor: AstBuilder.#executorOf(ctx),
272
376
  ...slots,
273
377
  body: AstBuilder.#bodyTextOf(ctx),
274
378
  position,
275
379
  };
276
380
  }
277
- // {§exec-executor-slot} — the executor may lead the heading or trail the path (once, unambiguous:
278
- // no other slot after a path uses `[...]`); two executors are the one rejected shape.
279
- static #executorOf(ctx, pos) {
280
- const found = ctx.EXECUTOR();
281
- const list = Array.isArray(found) ? found : found === null || found === undefined ? [] : [found];
282
- if (list.length > 1) {
283
- throw new PlurnkParseError(pos.line, pos.column, "visitor", "`### EXEC0` accepts one `[executor]`");
284
- }
285
- return list[0]?.getText().slice(1, -1) ?? null;
381
+ // {§executor-case} — the AST carries the registered spelling; the tag may be written in any case.
382
+ static executorSpellings = new Map();
383
+ static #executorOf(ctx) {
384
+ const name = ctx.OPEN_EXEC().getText().replace(/^`+[0-9]*/, "");
385
+ if (name === "EXEC")
386
+ return null;
387
+ return AstBuilder.executorSpellings.get(name.toLowerCase()) ?? name;
286
388
  }
287
389
  static #buildBare(ctx) {
288
390
  const position = AstBuilder.#positionOf(ctx);
391
+ const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
289
392
  return {
290
393
  op: "BARE",
291
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_BARE().getText(), "BARE"),
292
- annotation: AstBuilder.#annotationOf(ctx),
293
- target: null,
294
- metadata: null,
394
+ aside: AstBuilder.#asideOf(ctx),
395
+ target: slots.target,
396
+ metadata: slots.metadata,
295
397
  lineMarker: null,
296
398
  body: AstBuilder.#requiredBodyTextOf(ctx),
297
399
  position,
298
400
  };
299
401
  }
300
- static #buildPlan(ctx) {
301
- const position = AstBuilder.#positionOf(ctx);
302
- const slots = AstBuilder.#extractSlots(ctx.slotModifiers(), position);
303
- const rejected = [
304
- slots.target !== null ? "(path)" : null,
305
- slots.metadata !== null ? "{metadata}" : null,
306
- slots.lineMarker !== null ? "<scope>" : null,
307
- ].filter((slot) => slot !== null);
308
- if (rejected.length > 0) {
309
- throw new PlurnkParseError(position.line, position.column, "visitor", `PLAN does not accept ${AstBuilder.#joinTerms(rejected)}.`);
310
- }
311
- return {
312
- op: "PLAN",
313
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_PLAN().getText(), "PLAN"),
314
- annotation: AstBuilder.#annotationOf(ctx),
315
- target: null,
316
- metadata: null,
317
- lineMarker: null,
318
- body: PlanValue.admit(AstBuilder.#requiredBodyTextOf(ctx)),
319
- position,
320
- };
321
- }
322
- static #joinTerms(terms) {
323
- if (terms.length < 2)
324
- return terms[0] ?? "";
325
- if (terms.length === 2)
326
- return `${terms[0]} and ${terms[1]}`;
327
- return `${terms.slice(0, -1).join(", ")}, and ${terms.at(-1)}`;
328
- }
329
402
  static #buildKill(ctx) {
330
403
  const position = AstBuilder.#positionOf(ctx);
331
404
  // {§kill-scope} — the scope names lines of a log body or of an entry; null kills the whole target.
332
405
  const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
333
- const raw = AstBuilder.#bodyTextOf(ctx);
406
+ const split = AstBuilder.#splitInlineBody(ctx, position);
407
+ AstBuilder.#adviseBody("KILL", split.below, position);
408
+ const lifted = AstBuilder.#liftMatcher("KILL", slots.metadata, position, split.inline ?? split.below, split.inline !== null, slots.lineMarker !== null);
334
409
  return {
335
410
  op: "KILL",
336
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_KILL().getText(), "KILL"),
337
- annotation: AstBuilder.#annotationOf(ctx),
411
+ aside: AstBuilder.#asideOf(ctx) ?? lifted.aside,
338
412
  ...slots,
339
- body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
413
+ lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
414
+ metadata: lifted.metadata,
415
+ matcher: lifted.matcher,
416
+ body: null,
340
417
  position,
341
418
  };
342
419
  }
@@ -345,8 +422,7 @@ export default class AstBuilder {
345
422
  const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
346
423
  return {
347
424
  op: "WORK",
348
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_WORK().getText(), "WORK"),
349
- annotation: AstBuilder.#annotationOf(ctx),
425
+ aside: AstBuilder.#asideOf(ctx),
350
426
  ...slots,
351
427
  lineMarker: null,
352
428
  body: AstBuilder.#requiredBodyTextOf(ctx),
@@ -358,8 +434,7 @@ export default class AstBuilder {
358
434
  const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
359
435
  return {
360
436
  op: "FORK",
361
- delimiter: AstBuilder.#splitDelimiter(ctx.OPEN_FORK().getText(), "FORK"),
362
- annotation: AstBuilder.#annotationOf(ctx),
437
+ aside: AstBuilder.#asideOf(ctx),
363
438
  ...slots,
364
439
  lineMarker: null,
365
440
  body: AstBuilder.#requiredBodyTextOf(ctx),
@@ -372,22 +447,27 @@ export default class AstBuilder {
372
447
  metadata: AstBuilder.#metadataFromCtx(ctx),
373
448
  };
374
449
  }
450
+ static #singleMarker(ctx, pos) {
451
+ const found = AstBuilder.#findAll(ctx, LineMarkerContext);
452
+ if (found.length > 1)
453
+ throw new PlurnkParseError(pos.line, pos.column, "visitor", "A resource selection takes at most one scope.");
454
+ return found[0] ?? null;
455
+ }
375
456
  static #extractSlots(modCtx, pos) {
376
457
  return {
377
458
  target: AstBuilder.#targetFromCtx(AstBuilder.#findFirst(modCtx, TargetContext), pos),
378
459
  metadata: AstBuilder.#metadataFromCtx(modCtx),
379
- lineMarker: AstBuilder.#lineMarkerFromCtx(AstBuilder.#findFirst(modCtx, LineMarkerContext)),
460
+ lineMarker: AstBuilder.#lineMarkerFromCtx(AstBuilder.#singleMarker(modCtx, pos)),
380
461
  };
381
462
  }
382
463
  static #extractTextSlots(modCtx, pos) {
383
464
  return {
384
465
  target: AstBuilder.#targetFromCtx(AstBuilder.#findFirst(modCtx, TargetContext), pos),
385
466
  metadata: AstBuilder.#metadataFromCtx(modCtx),
386
- lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#findFirst(modCtx, LineMarkerContext)),
467
+ lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#singleMarker(modCtx, pos)),
387
468
  };
388
469
  }
389
470
  // Depth-first search for the first terminal of `tokenType`; returns its text or null.
390
- // Lets the SEND/KILL signal read work regardless of which signal rule wrapped it.
391
471
  static #findToken(root, tokenType) {
392
472
  if (root === null)
393
473
  return null;
@@ -402,12 +482,12 @@ export default class AstBuilder {
402
482
  }
403
483
  return null;
404
484
  }
405
- static #extractExecSlots(modCtx, pos) {
485
+ static #extractExecSlots(modCtx, pos, executor) {
406
486
  // {§exec-executor-slot} — every slot at most once; the grammar admits any order.
407
487
  const once = (type, slot) => {
408
488
  const found = AstBuilder.#findAll(modCtx, type);
409
489
  if (found.length > 1) {
410
- throw new PlurnkParseError(pos.line, pos.column, "visitor", `\`### EXEC0\` accepts ${slot} at most once`);
490
+ throw new PlurnkParseError(pos.line, pos.column, "visitor", `${executor} accepts ${slot} at most once`);
411
491
  }
412
492
  return found[0] ?? null;
413
493
  };
@@ -444,6 +524,11 @@ export default class AstBuilder {
444
524
  static #targetFromCtx(ctx, pos) {
445
525
  if (ctx === null)
446
526
  return null;
527
+ const nestedScope = ctx.lineMarker();
528
+ if (nestedScope !== null) {
529
+ const point = AstBuilder.#positionOf(nestedScope);
530
+ AstBuilder.#advisories.push(new PlurnkParseError(point.line, point.column, "parser", "The scope was inside the target slot; it was applied as the operation scope.", "warning"));
531
+ }
447
532
  const text = ctx.TARGET_TEXT().map((token) => token.getText()).join("");
448
533
  return AstBuilder.parsePath(text, pos);
449
534
  }
@@ -457,10 +542,12 @@ export default class AstBuilder {
457
542
  const target = AstBuilder.#targetFromCtx(AstBuilder.#findFirst(ctx, TargetContext), pos);
458
543
  if (target === null)
459
544
  throw new Error("resource selection grammar did not produce a target");
545
+ const lifted = AstBuilder.#liftMatcher("COPY/MOVE", AstBuilder.#metadataFromCtx(ctx), pos);
460
546
  return {
461
547
  target,
462
- metadata: AstBuilder.#metadataFromCtx(ctx),
463
- lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#findFirst(ctx, LineMarkerContext)),
548
+ metadata: lifted.metadata,
549
+ lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#singleMarker(ctx, pos)),
550
+ matcher: lifted.matcher,
464
551
  };
465
552
  }
466
553
  static #lineMarkerFromCtx(ctx) {
@@ -473,32 +560,66 @@ export default class AstBuilder {
473
560
  if (ctx === null)
474
561
  return null;
475
562
  const text = ctx.L_MARKER()?.getText() ?? "";
476
- return AstBuilder.#parseTextLineMarker(text);
563
+ return AstBuilder.#parseTextLineMarker(text, AstBuilder.#positionOf(ctx));
477
564
  }
478
- static #parseTextLineMarker(text) {
565
+ static #parseTextLineMarker(text, position) {
479
566
  if (!text.includes("@"))
480
567
  return AstBuilder.#parseLineMarker(text);
481
- const marks = text.slice(1, -1).split(/, ?/).map((component) => component.startsWith("@") ? component : Number.parseFloat(component));
568
+ const marks = text.slice(1, -1).split(/, ?/).map((component) => {
569
+ // {§anchor-digits} — `@210` is the line number 210 with the anchor's sigil, not a hash.
570
+ if (/^@[0-9]{1,4}$/u.test(component)) {
571
+ if (position !== undefined) {
572
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `\`${component}\` was read as line ${component.slice(1)}; an anchor is five characters (\`@abcde\`).`, "warning"));
573
+ }
574
+ return Number.parseInt(component.slice(1), 10);
575
+ }
576
+ // {§combined-anchor-tolerance} — `@abcde 42` / `@abcde:42` is the displayed prefix copied whole;
577
+ // the anchor is the coordinate and the number is dropped.
578
+ const combined = /^(@[0-9A-Za-z]{5})[: ][1-9][0-9]*$/u.exec(component);
579
+ if (combined !== null) {
580
+ if (position !== undefined) {
581
+ AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `\`${component}\` was read as the anchor \`${combined[1]}\`; a scope position takes the anchor without its displayed line number.`, "warning"));
582
+ }
583
+ return combined[1];
584
+ }
585
+ return component.startsWith("@") ? component : Number.parseFloat(component);
586
+ });
482
587
  return { marks: marks };
483
588
  }
484
589
  static #positionOf(ctx) {
485
590
  const start = ctx.start;
486
591
  return { line: start?.line ?? 0, column: start?.column ?? 0 };
487
592
  }
488
- static #annotationOf(ctx) {
489
- const token = AstBuilder.#findToken(ctx, plurnkLexer.ANNOTATION);
490
- return token === null ? null : token.slice("<!--".length, -"-->".length).trim();
593
+ static #asideOf(ctx) {
594
+ const token = AstBuilder.#findToken(ctx, plurnkLexer.ASIDE);
595
+ if (token === null)
596
+ return null;
597
+ const inner = token.endsWith("-->") ? token.slice("<!--".length, -"-->".length) : token.slice("<!--".length);
598
+ return inner.trim();
491
599
  }
600
+ // {§closer-fallback} — without a real closer (the block ended at the next heading or at the end
601
+ // of the input) the body is cut back to its last bare fence line, which is the closer the model
602
+ // meant, and one terminating line ending goes with it. A synthetic SECTION_END carries no backtick.
492
603
  static #bodyTextOf(ctx) {
493
- return AstBuilder.#findFirst(ctx, BodyContext)?.getText() ?? null;
604
+ const text = AstBuilder.#findFirst(ctx, BodyContext)?.getText() ?? null;
605
+ if (text === null)
606
+ return null;
607
+ const closer = AstBuilder.#findToken(ctx, plurnkLexer.SECTION_END);
608
+ if (closer !== null && closer.includes("`"))
609
+ return text;
610
+ const lines = text.split("\n");
611
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
612
+ if (/^[ \t]*`{3,}[0-9]*[ \t]*\r?$/u.test(lines[index] ?? "")) {
613
+ const kept = lines.slice(0, index).join("\n");
614
+ return kept === "" ? null : kept;
615
+ }
616
+ }
617
+ const trimmed = text.replace(/\r?\n$/u, "");
618
+ return trimmed === "" ? null : trimmed;
494
619
  }
495
620
  static #requiredBodyTextOf(ctx) {
496
621
  return AstBuilder.#bodyTextOf(ctx) ?? "";
497
622
  }
498
- static #splitDelimiter(headingText, op) {
499
- const marker = op === "PLAN" ? "## " : "### ";
500
- return headingText.slice(marker.length + op.length);
501
- }
502
623
  static #isDigit(c) {
503
624
  return c !== undefined && c >= "0" && c <= "9";
504
625
  }
@@ -547,7 +668,12 @@ export default class AstBuilder {
547
668
  return null;
548
669
  const target = PathSyntax.unescapeTarget(raw);
549
670
  if (!AstBuilder.#SCHEME_PATTERN.test(target)) {
550
- return { kind: "local", raw: target };
671
+ // {§local-path-fragment} — `#channel` after a bare path is the channel, exactly as on a
672
+ // URL; a spelling that opens with `#` names no path, so it stays whole.
673
+ const hash = target.indexOf("#");
674
+ if (hash < 1)
675
+ return { kind: "local", raw: target };
676
+ return { kind: "local", raw: target.slice(0, hash), fragment: target.slice(hash + 1) };
551
677
  }
552
678
  const protectedTarget = AstBuilder.#protectPathBraces(target);
553
679
  let url;
@@ -621,7 +747,7 @@ export default class AstBuilder {
621
747
  const raw = body.replace(/(?:\r\n|\r|\n)$/u, "");
622
748
  const lineCount = raw.split(/\r\n|\r|\n/u).length;
623
749
  if (lineCount !== 1) {
624
- throw new PlurnkParseError(pos.line, pos.column, "visitor", `Matcher body has ${lineCount} lines; expected 1.`);
750
+ throw new PlurnkParseError(pos.line, pos.column, "visitor", `Matcher has ${lineCount} lines; expected 1.`);
625
751
  }
626
752
  if (raw.startsWith("//")) {
627
753
  try {
@@ -632,12 +758,24 @@ export default class AstBuilder {
632
758
  }
633
759
  return { dialect: "xpath", raw };
634
760
  }
761
+ // {§naked-pattern} — a matcher opening with `^` is a regex written without slashes or flags.
762
+ if (raw.startsWith("^")) {
763
+ const inline = AstBuilder.#liftInlineFlags(raw.slice(1), "", pos);
764
+ const pattern = `^${inline.pattern}`;
765
+ try {
766
+ new RegExp(pattern, inline.flags);
767
+ }
768
+ catch (e) {
769
+ throw new PlurnkParseError(pos.line, pos.column, "visitor", `pattern leads with \`^\` but is not a valid regex - ${AstBuilder.#detail(e)}`);
770
+ }
771
+ return { dialect: "regex", raw, pattern, flags: inline.flags };
772
+ }
635
773
  if (raw.startsWith("/")) {
636
- const regex = AstBuilder.#tryParseSlashRegex(raw);
774
+ const regex = AstBuilder.#tryParseSlashRegex(raw, pos);
637
775
  if (regex.ok)
638
776
  return { dialect: "regex", raw, pattern: regex.pattern, flags: regex.flags };
639
- if (regex.reason === "invalid" && /^[\t ]/u.test(regex.flags)) {
640
- throw new PlurnkParseError(pos.line, pos.column, "visitor", "Regex matcher has trailing text after its closing `/`; operation modifiers precede the line ending, and the matcher occupies the next line: `### FIND0 (path) <scope>` above, `/pattern/` below.");
777
+ if (regex.reason === "trailing") {
778
+ throw new PlurnkParseError(pos.line, pos.column, "visitor", "Regex matcher has trailing text after `/pattern/flags`.");
641
779
  }
642
780
  const slashRecovery = regex.reason === "invalid"
643
781
  && regex.detail.includes("Invalid flags supplied")
@@ -662,7 +800,7 @@ export default class AstBuilder {
662
800
  return { dialect: "jsonpath", raw };
663
801
  }
664
802
  if (raw.startsWith("~"))
665
- return { dialect: "semantic", raw };
803
+ return { dialect: "fts", raw };
666
804
  if (raw.startsWith("&")) {
667
805
  if (!AstBuilder.#GRAPH_MATCHER.test(raw)) {
668
806
  throw new PlurnkParseError(pos.line, pos.column, "visitor", "Malformed graph matcher; expected `&symbol`, `&<symbol`, or `&>symbol`.");
@@ -677,7 +815,18 @@ export default class AstBuilder {
677
815
  // Splits an ECMAScript `/pattern/flags` literal. Backslash escapes and character
678
816
  // classes keep a slash inside the pattern; the first unescaped slash outside a
679
817
  // class closes it. The native constructor owns pattern and flag validity.
680
- static #tryParseSlashRegex(raw) {
818
+ // {§inline-flag-tolerance} — a leading PCRE inline modifier such as `(?i)` is the pretrained
819
+ // spelling of a flag; ECMAScript refuses the group, so it is lifted into the flags with one
820
+ // advisory rather than refused.
821
+ static #liftInlineFlags(pattern, flags, pos) {
822
+ const inline = /^\(\?([ims]+)\)/u.exec(pattern);
823
+ if (inline === null)
824
+ return { pattern, flags };
825
+ const lifted = [...new Set([...flags, ...inline[1]])].join("");
826
+ AstBuilder.#advisories.push(new PlurnkParseError(pos.line, pos.column, "parser", `\`${inline[0]}\` was read as the \`${inline[1]}\` flag; an ECMAScript regex takes its flags after the closing \`/\`.`, "warning"));
827
+ return { pattern: pattern.slice(inline[0].length), flags: lifted };
828
+ }
829
+ static #tryParseSlashRegex(raw, pos) {
681
830
  let i = 1;
682
831
  let inClass = false;
683
832
  while (i < raw.length) {
@@ -701,14 +850,18 @@ export default class AstBuilder {
701
850
  }
702
851
  if (i >= raw.length)
703
852
  return { ok: false, reason: "unclosed" };
704
- const pattern = raw.slice(1, i);
705
- const flags = raw.slice(i + 1);
853
+ const authored = raw.slice(i + 1);
854
+ const trailing = /^([A-Za-z]*)[\t ]/u.exec(authored);
855
+ const inline = AstBuilder.#liftInlineFlags(raw.slice(1, i), trailing?.[1] ?? authored, pos);
856
+ const { pattern, flags } = inline;
706
857
  try {
707
858
  new RegExp(pattern, flags);
708
859
  }
709
860
  catch (e) {
710
861
  return { ok: false, reason: "invalid", detail: AstBuilder.#detail(e), flags };
711
862
  }
863
+ if (trailing !== null)
864
+ return { ok: false, reason: "trailing" };
712
865
  return { ok: true, pattern, flags };
713
866
  }
714
867
  static #parseSendBody(raw) {