@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 +93 -0
- package/dist/dsl/index.d.ts +37 -9
- package/dist/dsl/index.js +340 -12
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +24 -3
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dsl/check.ts +391 -7
- package/src/dsl/index.ts +2 -1
- package/src/dsl/lexer.ts +31 -1
- package/src/dsl/parse.ts +93 -19
- package/src/dsl/types.ts +8 -0
- package/src/index.ts +1 -0
- package/src/traits.ts +49 -9
- package/src/types.ts +15 -0
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
|
package/dist/dsl/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
585
|
-
if (!
|
|
586
|
-
labels.push(
|
|
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
|
|
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
|
|
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 (
|
|
1104
|
+
if (label && !isParamDecl) {
|
|
989
1105
|
add(
|
|
990
|
-
|
|
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
|