@dforge-core/metadata 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,99 @@ All notable changes to `@dforge-core/metadata` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.25] — 2026-09-15
9
+
10
+ ### Added
11
+
12
+ - **`templateChunkValue()` and the `StringArg` type** are documented as exports of
13
+ `@dforge-core/metadata/dsl`. Both landed in 0.0.24 without an entry:
14
+ `CallRef.firstStringArg` became a `StringArg`, which reads a template literal as
15
+ well as a quoted string — reassembled from the chunks the lexer splits one into,
16
+ with `interpolated` marking the `${…}` holes and a space standing in for each —
17
+ so a rule that reads a call's first argument sees the multi-line form too.
18
+
19
+ ### Fixed
20
+
21
+ - **`dsl/unknown-builtin` scopes a declared helper to its block, because the
22
+ compiler does.** `execute:` and `onBeforeStart:` are compiled to separate
23
+ scripts and each gets its own `CheckUndefinedReferences` pass, so a `function`
24
+ declared in one is undefined in the other — and the rule, which read
25
+ declarations across the whole file, accepted the call that install rejects. A
26
+ declaration above the first header belongs to no block and stays visible to
27
+ both; nothing legal can sit there.
28
+
29
+ - **`dsl/job-record-context` stands down on a `records` the script binds
30
+ itself.** `var records = select(…)` is the very refactor the rule asks for, so
31
+ reporting it left the author with a diagnostic that its own fix could not
32
+ clear. Shadowing is read exactly as the execution-mode rule already reads it.
33
+
34
+ ## [0.0.24] — 2026-09-15
35
+
36
+ ### Added
37
+
38
+ - **Module-local traits now expand.** `expandTrait` / `expandTraits` take an optional
39
+ third argument: a module's own root `traits.json` (new `TraitsFile` type). A module
40
+ may declare traits of its own, and an entity names them exactly like a platform trait
41
+ — so a reader that knows only this registry sees an entity missing columns that
42
+ install will give it. In the editor that surfaced as eight false "unknown column"
43
+ errors on `file-library/summarize.dsl`, whose `file_item` entity takes
44
+ `processing_status` / `processing_result` / `processing_error` from the module's own
45
+ `file-library` trait.
46
+
47
+ A code declared in both wins locally, mirroring the installer
48
+ (`TraitExpanderFactory.ForPackage` overlays the module's traits on the platform ones).
49
+ Expansion is now cycle-guarded: `includes` is author-supplied once local traits are in
50
+ play, and an unguarded walk is an editor hang rather than a bad expansion. The guard is
51
+ the current path rather than every trait already seen, so a trait two includes both
52
+ reach still expands under each — otherwise which definition won a key collision would
53
+ depend on which branch got there first.
54
+
55
+ - **The DSL checker gained the rules that lived only in the module validator**
56
+ (`dforge-mcp`'s regex `dsl-check.ts`), ported onto the token stream:
57
+ `dsl/empty-script`, `dsl/missing-execute`, `dsl/duplicate-block`, `dsl/block-order`,
58
+ `dsl/formula-only-function` (`TODAY()` / `NOW()` / `CURRENT_USER_ID()` in a JavaScript
59
+ block), `dsl/sql-placeholder` (`:name` where the platform binds `@name`),
60
+ `dsl/sql-concat`, `dsl/unknown-builtin` and `dsl/job-record-context`. `DslContext.action`
61
+ gained `viaJob` for the last of these — a scheduled job runs as the system user with no
62
+ current record, which only a reader holding the module's jobs can know.
63
+
64
+ Being on the token stream rather than on regexes over blanked source, several are
65
+ sharper than the originals: `::text` is no longer read as a `:name` placeholder, a
66
+ statement built from a variable is skipped rather than guessed at, and a declared
67
+ helper — including the method shorthand `f(x) { … }` — is not an unknown built-in.
68
+
69
+ Each honours the disambiguation the older rules already carry. `dsl/sql-placeholder`
70
+ reads the statement with its string literals and comments blanked, so `':draft'` is
71
+ quoted data rather than a binding. `dsl/job-record-context` skips a local's array
72
+ literal and, in batch `execute:`, a keyword one — the same terms the column rule
73
+ uses. And the formula-only table is a `Map`, since its key is a name out of the
74
+ script and an object would answer `toString` from the prototype, reporting a
75
+ declared helper as formula-only and offering native source as the fix.
76
+ `stringValue()` now decodes `\n`, `\t` and `\r` rather than dropping the
77
+ backslash, so a multi-line SQL literal reads as the statement it is.
78
+
79
+ `dsl/block-order` follows `ParseBlocks` per block rather than as one sequence.
80
+ Each header regex has its own lookahead set, and only a block the one above it
81
+ omits is swallowed — `schema:` is listed by every other set, so it may sit
82
+ anywhere before `execute:`, which the earlier rank comparison rejected.
83
+ `dsl/job-record-context` covers all three forms `JobRegistrar.RequiresRecordContext`
84
+ rejects, not `[field]` alone: `old[field]` and `records` are record context too.
85
+ Both SQL rules read a template literal, which is how multi-line SQL is written
86
+ — a `${…}` hole counts as concatenation, and the chunks either side of it are
87
+ still scanned for `:name`. And `Set`, `Map`, `Promise`, `Symbol`, `BigInt` and
88
+ `encodeURI` / `decodeURI` joined the globals `dsl/unknown-builtin` stands down
89
+ on; Jint exposes them and `new Set()` was drawing a warning. A constructor is
90
+ skipped outright: `new Foo()` names no host function for the catalog to hold.
91
+
92
+ ### Changed
93
+
94
+ - **`BLOCK_KINDS` is a `readonly` tuple**, not `BlockKind[]`. `BlockKind` now
95
+ derives from it, so the order check and the type it ranks cannot drift. The
96
+ narrowing is visible to callers: `BLOCK_KINDS.includes(s)` no longer accepts a
97
+ plain `string`, and the array can no longer be assigned to a mutable
98
+ `BlockKind[]`. Test the value first (`BLOCK_KINDS.some((k) => k === s)`) where
99
+ that bites.
100
+
8
101
  ## [0.0.23] — 2026-09-14
9
102
 
10
103
  ### Added
@@ -15,10 +15,22 @@ interface Token {
15
15
  declare function tokenize(text: string): Token[];
16
16
  /** Strip the surrounding quotes from a string token's text. */
17
17
  declare function stringValue(token: Token): string;
18
+ /**
19
+ * The literal text of one template chunk, delimiters removed. A chunk opens on
20
+ * a backtick or on the `}` that closed the hole before it, and closes on a
21
+ * backtick, on the `${` of the next hole, or on the end of the file.
22
+ */
23
+ declare function templateChunkValue(token: Token): string;
18
24
 
19
- type BlockKind = "params" | "canExecute" | "schema" | "onBeforeStart" | "execute";
20
- /** In the order `ActionDslCompiler.ParseBlocks` expects them in a file. */
21
- declare const BLOCK_KINDS: BlockKind[];
25
+ /**
26
+ * In the order `ActionDslCompiler.ParseBlocks` expects them in a file.
27
+ *
28
+ * `BlockKind` derives from this array rather than standing beside it, so the
29
+ * two cannot drift: the order check ranks a kind by its index here, and a kind
30
+ * the array does not list could not have been produced in the first place.
31
+ */
32
+ declare const BLOCK_KINDS: readonly ["params", "canExecute", "schema", "onBeforeStart", "execute"];
33
+ type BlockKind = (typeof BLOCK_KINDS)[number];
22
34
  interface Span {
23
35
  start: number;
24
36
  end: number;
@@ -84,13 +96,21 @@ interface GlobalRef {
84
96
  interface CallRef {
85
97
  name: string;
86
98
  nameSpan: Span;
87
- /** First argument when it is a string literal — the entity code, usually. */
88
- firstStringArg?: {
89
- value: string;
90
- span: Span;
91
- };
99
+ /** First argument when it is a string or template literal — the entity code, usually. */
100
+ firstStringArg?: StringArg;
92
101
  block: BlockKind | null;
93
102
  }
103
+ interface StringArg {
104
+ value: string;
105
+ span: Span;
106
+ /**
107
+ * A template literal with `${…}` holes, which `value` holds a space in
108
+ * place of: literal text to read, but not a constant.
109
+ */
110
+ interpolated: boolean;
111
+ /** Index of the last token the literal consumes — its final chunk. */
112
+ endIndex: number;
113
+ }
94
114
  interface DslDocument {
95
115
  blocks: DslBlock[];
96
116
  params: DslParam[];
@@ -200,6 +220,14 @@ interface DslContext {
200
220
  action?: {
201
221
  code: string;
202
222
  executionMode?: ActionExecutionMode;
223
+ /**
224
+ * True when a scheduled job invokes this action. A job runs as the
225
+ * system user with no current record, so record context is a hard
226
+ * error there even in a mode that would otherwise allow it. Only a
227
+ * reader that has the module's jobs can know this, so it is the host's
228
+ * to supply; absent, the rule stands down like every other.
229
+ */
230
+ viaJob?: boolean;
203
231
  };
204
232
  /**
205
233
  * Entity behind the record context (`[field]`). `null` or absent when it
@@ -220,4 +248,4 @@ interface DslContext {
220
248
  */
221
249
  declare function checkDsl(text: string, ctx?: DslContext): DslIssue[];
222
250
 
223
- export { BLOCK_KINDS, BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES, type BlockKind, type Builtin, type CallRef, type ColumnLookup, type DslBlock, type DslContext, type DslDocument, type DslIssue, type DslParam, type DslSeverity, type EntityShape, type FieldRef, type GlobalRef, type Span, type Token, type TokenKind, checkDsl, parseDsl, stringValue, tokenize };
251
+ export { BLOCK_KINDS, BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES, type BlockKind, type Builtin, type CallRef, type ColumnLookup, type DslBlock, type DslContext, type DslDocument, type DslIssue, type DslParam, type DslSeverity, type EntityShape, type FieldRef, type GlobalRef, type Span, type StringArg, type Token, type TokenKind, checkDsl, parseDsl, stringValue, templateChunkValue, tokenize };
package/dist/dsl/index.js CHANGED
@@ -269,7 +269,28 @@ function scanRegex(text, start) {
269
269
  function stringValue(token) {
270
270
  if (token.kind !== "string") return token.text;
271
271
  const body = token.text.slice(1, token.text.endsWith(token.text[0]) && token.text.length > 1 ? -1 : void 0);
272
- return body.replace(/\\(.)/g, "$1");
272
+ return unescape(body);
273
+ }
274
+ function templateChunkValue(token) {
275
+ if (token.kind !== "template") return token.text;
276
+ const body = token.text.slice(1);
277
+ if (body.endsWith("${")) return unescape(body.slice(0, -2));
278
+ if (body.endsWith("`")) return unescape(body.slice(0, -1));
279
+ return unescape(body);
280
+ }
281
+ function unescape(body) {
282
+ return body.replace(/\\(.)/g, (_, ch) => {
283
+ switch (ch) {
284
+ case "n":
285
+ return "\n";
286
+ case "t":
287
+ return " ";
288
+ case "r":
289
+ return "\r";
290
+ default:
291
+ return ch;
292
+ }
293
+ });
273
294
  }
274
295
 
275
296
  // src/dsl/parse.ts
@@ -380,8 +401,12 @@ function parseDsl(text) {
380
401
  if (firstArg?.kind === "string") {
381
402
  call.firstStringArg = {
382
403
  value: stringValue(firstArg),
383
- span: { start: firstArg.start, end: firstArg.end }
404
+ span: { start: firstArg.start, end: firstArg.end },
405
+ interpolated: false,
406
+ endIndex: i + 2
384
407
  };
408
+ } else if (firstArg?.kind === "template" && firstArg.text.startsWith("`")) {
409
+ call.firstStringArg = templateArg(tokens, i + 2);
385
410
  }
386
411
  calls.push(call);
387
412
  }
@@ -401,6 +426,37 @@ function parseDsl(text) {
401
426
  function isFieldAccessStart(text, start) {
402
427
  return start === 0 || !/\w/.test(text[start - 1]);
403
428
  }
429
+ function templateArg(tokens, start) {
430
+ const parts = [];
431
+ let depth = 0;
432
+ let interpolated = false;
433
+ let last = tokens[start];
434
+ let endIndex = start;
435
+ for (let j = start; j < tokens.length; j++) {
436
+ const t = tokens[j];
437
+ if (t.kind !== "template") continue;
438
+ const opens = t.text.startsWith("`");
439
+ if (opens) depth++;
440
+ if (depth === 1) {
441
+ if (!opens) parts.push(" ");
442
+ parts.push(templateChunkValue(t));
443
+ last = t;
444
+ endIndex = j;
445
+ }
446
+ if (t.text.endsWith("${")) {
447
+ if (depth === 1) interpolated = true;
448
+ continue;
449
+ }
450
+ depth--;
451
+ if (depth === 0) break;
452
+ }
453
+ return {
454
+ value: parts.join(""),
455
+ span: { start: tokens[start].start, end: last.end },
456
+ interpolated,
457
+ endIndex
458
+ };
459
+ }
404
460
  function indexedRecordField(tokens, i) {
405
461
  const index = tokens[i + 2];
406
462
  if (tokens[i + 1]?.text !== "[") return null;
@@ -574,17 +630,18 @@ function blockLabelAt(tokens, i) {
574
630
  if (!t || t.kind !== "ident" || !t.startsLine) return null;
575
631
  const colon = tokens[i + 1];
576
632
  if (colon?.text !== ":" || colon.start !== t.end) return null;
577
- return BLOCK_BY_LOWER.get(t.text.toLowerCase()) ?? null;
633
+ const kind = BLOCK_BY_LOWER.get(t.text.toLowerCase());
634
+ return kind ? { kind, span: { start: t.start, end: colon.end } } : null;
578
635
  }
579
636
  function findBlocks(text, tokens) {
580
637
  const labels = [];
581
638
  for (let i = 0; i < tokens.length; i++) {
582
639
  const t = tokens[i];
583
640
  if (t.character !== 0) continue;
584
- const kind = blockLabelAt(tokens, i);
585
- if (!kind) continue;
586
- labels.push({ kind, span: { start: t.start, end: tokens[i + 1].end } });
587
- if (kind === "execute") break;
641
+ const label = blockLabelAt(tokens, i);
642
+ if (!label) continue;
643
+ labels.push(label);
644
+ if (label.kind === "execute") break;
588
645
  }
589
646
  return labels.map((l, idx) => ({
590
647
  kind: l.kind,
@@ -854,6 +911,64 @@ function checkDsl(text, ctx = {}) {
854
911
  const entity = ctx.currentEntity ?? null;
855
912
  const isBatch = (action?.executionMode ?? "single") === "batch";
856
913
  const hasColumn = columnMatcher(entity?.columns);
914
+ if (text.trim() === "") {
915
+ add(
916
+ { start: 0, end: text.length },
917
+ "A DSL body needs at least an execute: block \u2014 an empty file installs an action that does nothing.",
918
+ "error",
919
+ "dsl/empty-script"
920
+ );
921
+ return out;
922
+ }
923
+ const firstToken = parsed.tokens[0];
924
+ const firstLabel = parsed.blocks[0]?.label ?? (firstToken ? { start: firstToken.start, end: firstToken.end } : { start: 0, end: text.length });
925
+ if (!parsed.blocks.some((b) => b.kind === "execute")) {
926
+ add(
927
+ firstLabel,
928
+ "No execute: block \u2014 it is the only required block, and the compiler runs nothing without it.",
929
+ "error",
930
+ "dsl/missing-execute"
931
+ );
932
+ }
933
+ const executeBlock = parsed.blocks.find((b) => b.kind === "execute");
934
+ if (executeBlock) {
935
+ for (let i = 0; i < parsed.tokens.length; i++) {
936
+ const token = parsed.tokens[i];
937
+ if (token.start <= executeBlock.label.start) continue;
938
+ if (token.character !== 0) continue;
939
+ const label = blockLabelAt(parsed.tokens, i);
940
+ if (!label) continue;
941
+ add(
942
+ label.span,
943
+ label.kind === "execute" ? `"execute:" is declared more than once \u2014 the first one's body runs to the end of the file, so this header and everything under it are swallowed into it and parsed as JavaScript. Merge the two into one execute: block.` : `"${label.kind}:" is below execute:, whose body runs to the end of the file \u2014 the header and everything under it are swallowed into the execute: script and parsed as JavaScript. Move it above execute:.`,
944
+ "error",
945
+ "dsl/block-after-execute"
946
+ );
947
+ }
948
+ }
949
+ const seenBlocks = /* @__PURE__ */ new Set();
950
+ for (let i = 0; i < parsed.blocks.length; i++) {
951
+ const block = parsed.blocks[i];
952
+ if (seenBlocks.has(block.kind)) {
953
+ add(
954
+ block.label,
955
+ `"${block.kind}:" is declared more than once \u2014 the compiler keeps one and silently drops the other.`,
956
+ "error",
957
+ "dsl/duplicate-block"
958
+ );
959
+ continue;
960
+ }
961
+ seenBlocks.add(block.kind);
962
+ const prev = parsed.blocks[i - 1];
963
+ if (prev && prev.kind !== "execute" && !BLOCK_STOPS[prev.kind].has(block.kind)) {
964
+ add(
965
+ block.label,
966
+ `"${block.kind}:" must come before "${prev.kind}:" \u2014 the compiler reads ${prev.kind}: up to the next header it recognises, and ${block.kind}: is not one of them, so this header and its body end up inside the ${prev.kind}: body.`,
967
+ "error",
968
+ "dsl/block-order"
969
+ );
970
+ }
971
+ }
857
972
  for (const ref of parsed.fieldRefs) {
858
973
  if (ref.block === "schema" || ref.block === "params") continue;
859
974
  const isDslBlock = ref.block === "execute" || ref.block === "onBeforeStart";
@@ -945,7 +1060,7 @@ function checkDsl(text, ctx = {}) {
945
1060
  if (call.name === "userId") {
946
1061
  add(
947
1062
  call.nameSpan,
948
- "userId is a bare identifier \u2014 userId() is a compile error.",
1063
+ "'userId' is a value, not a function \u2014 write userId without parentheses, or currentUserId(). The compiler's bare-identifier rewrite fires either way, so userId() reaches Jint as a call against a number.",
949
1064
  "error",
950
1065
  "dsl/user-id-call"
951
1066
  );
@@ -955,6 +1070,7 @@ function checkDsl(text, ctx = {}) {
955
1070
  if (!builtin || builtin.entityArg === void 0) continue;
956
1071
  const arg = call.firstStringArg;
957
1072
  if (!arg) continue;
1073
+ if (arg.interpolated) continue;
958
1074
  if (arg.value.includes(".") || ctx.moduleCode?.includes("-")) continue;
959
1075
  add(
960
1076
  arg.span,
@@ -981,14 +1097,14 @@ function checkDsl(text, ctx = {}) {
981
1097
  depth = Math.max(0, depth - 1);
982
1098
  }
983
1099
  if (depth === 0 && token.character > 0) {
984
- const kind = blockLabelAt(parsed.tokens, i);
1100
+ const label = blockLabelAt(parsed.tokens, i);
985
1101
  const isParamDecl = parsed.params.some(
986
1102
  (p) => p.nameSpan.start === token.start
987
1103
  );
988
- if (kind && !isParamDecl) {
1104
+ if (label && !isParamDecl) {
989
1105
  add(
990
- { start: token.start, end: parsed.tokens[i + 1].end },
991
- `Block headers start at column 0. Indented, "${kind}:" is body text \u2014 the compiler never opens the block, and it runs as if empty.`,
1106
+ label.span,
1107
+ `Block headers start at column 0. Indented, "${label.kind}:" is body text \u2014 the compiler never opens the block, and it runs as if empty.`,
992
1108
  "error",
993
1109
  "dsl/indented-block-header"
994
1110
  );
@@ -1008,6 +1124,111 @@ function checkDsl(text, ctx = {}) {
1008
1124
  "dsl/top-level-return"
1009
1125
  );
1010
1126
  }
1127
+ for (const call of parsed.calls) {
1128
+ const replacement = FORMULA_ONLY.get(call.name);
1129
+ if (!replacement) continue;
1130
+ if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
1131
+ add(
1132
+ call.nameSpan,
1133
+ `${call.name}() is formula-only \u2014 in ${call.block}: it is undefined and install fails with "'${call.name}' is not defined". Write ${replacement}.`,
1134
+ "error",
1135
+ "dsl/formula-only-function"
1136
+ );
1137
+ }
1138
+ const tokenAt = new Map(parsed.tokens.map((t, i) => [t.start, i]));
1139
+ for (const call of parsed.calls) {
1140
+ if (call.name !== "query" && call.name !== "callProc") continue;
1141
+ const arg = call.firstStringArg;
1142
+ if (!arg) continue;
1143
+ const bad = /(^|[^:\w]):([a-z][a-z0-9_]*)/i.exec(stripSqlNoise(arg.value));
1144
+ if (bad) {
1145
+ const name = bad[2];
1146
+ add(
1147
+ arg.span,
1148
+ `SQL binds @name, not :name \u2014 rewrite ':${name}' as '@${name}' and pass { ${name}: value } as the params argument.`,
1149
+ "error",
1150
+ "dsl/sql-placeholder"
1151
+ );
1152
+ }
1153
+ if (arg.interpolated || parsed.tokens[arg.endIndex + 1]?.text === "+") {
1154
+ const built = arg.interpolated ? "${\u2026} interpolation" : "concatenation";
1155
+ add(
1156
+ arg.span,
1157
+ // callProc's first argument is the procedure name, and an
1158
+ // identifier cannot be bound — the @placeholder advice cannot
1159
+ // be followed there.
1160
+ call.name === "callProc" ? `Procedure name built by ${built} \u2014 a name is an identifier, so it cannot be bound as a @placeholder. Choose between fixed names in the script rather than splicing a value into one.` : `SQL built by ${built} \u2014 pass @placeholders and a params object instead, so values are bound rather than spliced into the statement.`,
1161
+ "warning",
1162
+ "dsl/sql-concat"
1163
+ );
1164
+ }
1165
+ }
1166
+ const declaredFunctions = /* @__PURE__ */ new Map();
1167
+ for (let i = 0; i < parsed.tokens.length; i++) {
1168
+ const token = parsed.tokens[i];
1169
+ if (token.text !== "function") continue;
1170
+ const name = parsed.tokens[i + 1];
1171
+ if (name?.kind !== "ident") continue;
1172
+ const blocks = declaredFunctions.get(name.text) ?? /* @__PURE__ */ new Set();
1173
+ blocks.add(blockOf(parsed, token.start));
1174
+ declaredFunctions.set(name.text, blocks);
1175
+ }
1176
+ const isDeclaredIn = (name, block) => {
1177
+ const blocks = declaredFunctions.get(name);
1178
+ return blocks !== void 0 && (blocks.has(null) || blocks.has(block));
1179
+ };
1180
+ const reportedUnknown = /* @__PURE__ */ new Set();
1181
+ for (const call of parsed.calls) {
1182
+ if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
1183
+ const name = call.name;
1184
+ const idx = tokenAt.get(call.nameSpan.start);
1185
+ if (idx !== void 0) {
1186
+ const prev = parsed.tokens[idx - 1]?.text;
1187
+ if (prev === "function" || prev === "new") continue;
1188
+ if (isMethodName(parsed.tokens, idx)) continue;
1189
+ }
1190
+ if (BUILTIN_BY_NAME.has(name) || CONTROL_KEYWORDS.has(name) || KEYWORDS_BEFORE_PAREN.has(name) || JS_GLOBALS.has(name) || FORMULA_ONLY.has(name) || // Its own rule reports this one, with better wording.
1191
+ name === "userId" || isDeclaredIn(name, call.block) || parsed.locals.has(name) || reportedUnknown.has(name)) {
1192
+ continue;
1193
+ }
1194
+ reportedUnknown.add(name);
1195
+ add(
1196
+ call.nameSpan,
1197
+ `"${name}()" is not a DSL host function, and nothing in this script declares it. Check it against the built-in catalog.`,
1198
+ "warning",
1199
+ "dsl/unknown-builtin"
1200
+ );
1201
+ }
1202
+ if (ctx.action?.viaJob) {
1203
+ const inScript = (block) => block === "execute" || block === "onBeforeStart";
1204
+ const bound = [];
1205
+ for (const ref of parsed.fieldRefs) {
1206
+ if (!inScript(ref.block)) continue;
1207
+ if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
1208
+ continue;
1209
+ if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
1210
+ bound.push({ span: ref.outerSpan, read: `[${ref.name}]` });
1211
+ }
1212
+ for (const ref of parsed.globalRefs) {
1213
+ if (ref.global !== "old" || !inScript(ref.block)) continue;
1214
+ bound.push({ span: ref.span, read: `old[${ref.property}]` });
1215
+ }
1216
+ if (!parsed.locals.has("records")) {
1217
+ for (const span of parsed.recordsRefs) {
1218
+ if (!inScript(blockOf(parsed, span.start))) continue;
1219
+ bound.push({ span, read: "records" });
1220
+ }
1221
+ }
1222
+ const first = bound.sort((a, b) => a.span.start - b.span.start)[0];
1223
+ if (first) {
1224
+ add(
1225
+ first.span,
1226
+ `Action "${ctx.action.code}" is invoked by a scheduled job, which runs as the system user with no record bound \u2014 ${first.read} has nothing to read. Fetch rows with select() or query() instead.`,
1227
+ "error",
1228
+ "dsl/job-record-context"
1229
+ );
1230
+ }
1231
+ }
1011
1232
  out.push(...inlineAssignmentIssues(parsed, positionAt));
1012
1233
  return out;
1013
1234
  }
@@ -1039,6 +1260,62 @@ function opensFunctionBody(tokens, i, matchingParenOpen) {
1039
1260
  const head = tokens[matchingParenOpen - 1];
1040
1261
  return head?.kind === "ident" && !CONTROL_KEYWORDS.has(head.text);
1041
1262
  }
1263
+ var BLOCK_STOPS = {
1264
+ params: /* @__PURE__ */ new Set(["schema", "canExecute", "onBeforeStart", "execute"]),
1265
+ canExecute: /* @__PURE__ */ new Set(["schema", "onBeforeStart", "execute"]),
1266
+ schema: /* @__PURE__ */ new Set(["params", "canExecute", "onBeforeStart", "execute"]),
1267
+ onBeforeStart: /* @__PURE__ */ new Set(["schema", "execute"]),
1268
+ execute: /* @__PURE__ */ new Set()
1269
+ };
1270
+ var FORMULA_ONLY = /* @__PURE__ */ new Map([
1271
+ ["TODAY", "now()"],
1272
+ ["NOW", "now()"],
1273
+ ["CURRENT_USER_ID", "currentUserId()"]
1274
+ ]);
1275
+ var KEYWORDS_BEFORE_PAREN = /* @__PURE__ */ new Set([
1276
+ "function",
1277
+ "return",
1278
+ "typeof",
1279
+ "instanceof",
1280
+ "new",
1281
+ "delete",
1282
+ "void",
1283
+ "in",
1284
+ "of",
1285
+ "do",
1286
+ "else",
1287
+ "try",
1288
+ "throw",
1289
+ "yield",
1290
+ "await"
1291
+ ]);
1292
+ var JS_GLOBALS = /* @__PURE__ */ new Set([
1293
+ "parseInt",
1294
+ "parseFloat",
1295
+ "isNaN",
1296
+ "isFinite",
1297
+ "encodeURI",
1298
+ "decodeURI",
1299
+ "encodeURIComponent",
1300
+ "decodeURIComponent",
1301
+ "String",
1302
+ "Number",
1303
+ "Boolean",
1304
+ "Array",
1305
+ "Object",
1306
+ "Date",
1307
+ "Math",
1308
+ "JSON",
1309
+ "RegExp",
1310
+ "Error",
1311
+ "Set",
1312
+ "Map",
1313
+ "WeakSet",
1314
+ "WeakMap",
1315
+ "Promise",
1316
+ "Symbol",
1317
+ "BigInt"
1318
+ ]);
1042
1319
  var LITERAL_NAMES = /* @__PURE__ */ new Set([
1043
1320
  "true",
1044
1321
  "false",
@@ -1047,6 +1324,56 @@ var LITERAL_NAMES = /* @__PURE__ */ new Set([
1047
1324
  "FALSE",
1048
1325
  "NULL"
1049
1326
  ]);
1327
+ function stripSqlNoise(sql) {
1328
+ let out = "";
1329
+ let i = 0;
1330
+ while (i < sql.length) {
1331
+ const c = sql[i];
1332
+ if (c === "'" || c === '"') {
1333
+ const quote = c;
1334
+ out += " ";
1335
+ i++;
1336
+ while (i < sql.length) {
1337
+ if (sql[i] === quote && sql[i + 1] === quote) {
1338
+ out += " ";
1339
+ i += 2;
1340
+ continue;
1341
+ }
1342
+ if (sql[i] === quote) break;
1343
+ out += sql[i] === "\n" ? "\n" : " ";
1344
+ i++;
1345
+ }
1346
+ if (i < sql.length) {
1347
+ out += " ";
1348
+ i++;
1349
+ }
1350
+ continue;
1351
+ }
1352
+ if (c === "-" && sql[i + 1] === "-") {
1353
+ while (i < sql.length && sql[i] !== "\n") {
1354
+ out += " ";
1355
+ i++;
1356
+ }
1357
+ continue;
1358
+ }
1359
+ if (c === "/" && sql[i + 1] === "*") {
1360
+ out += " ";
1361
+ i += 2;
1362
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
1363
+ out += sql[i] === "\n" ? "\n" : " ";
1364
+ i++;
1365
+ }
1366
+ if (i < sql.length) {
1367
+ out += " ";
1368
+ i += 2;
1369
+ }
1370
+ continue;
1371
+ }
1372
+ out += c;
1373
+ i++;
1374
+ }
1375
+ return out;
1376
+ }
1050
1377
  function blockOf(parsed, offset) {
1051
1378
  return parsed.blocks.find((b) => offset >= b.body.start && offset < b.body.end)?.kind ?? null;
1052
1379
  }
@@ -1103,6 +1430,7 @@ export {
1103
1430
  checkDsl,
1104
1431
  parseDsl,
1105
1432
  stringValue,
1433
+ templateChunkValue,
1106
1434
  tokenize
1107
1435
  };
1108
1436
  //# sourceMappingURL=index.js.map