@dforge-core/metadata 0.0.20 → 0.0.23
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 +154 -0
- package/README.md +47 -1
- package/dist/actions-DvYTrCbP.d.ts +60 -0
- package/dist/dsl/index.d.ts +223 -0
- package/dist/dsl/index.js +1108 -0
- package/dist/dsl/index.js.map +1 -0
- package/dist/index.d.ts +3 -72
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/schemas/entity.schema.json +49 -5
- package/schemas/jobs.schema.json +2 -2
- package/schemas/reports.schema.json +1 -1
- package/src/dsl/builtins.ts +210 -0
- package/src/dsl/check.ts +450 -0
- package/src/dsl/index.ts +36 -0
- package/src/dsl/lexer.ts +364 -0
- package/src/dsl/parse.ts +639 -0
- package/src/dsl/types.ts +90 -0
- package/src/field-types.ts +3 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The DSL's platform globals. Signatures and prose track
|
|
2
|
+
// dforge-mcp/skills/dforge-mcp-author/references/action-dsl.md — that file is
|
|
3
|
+
// the source of truth; re-sync when it changes.
|
|
4
|
+
|
|
5
|
+
export interface Builtin {
|
|
6
|
+
name: string;
|
|
7
|
+
signature: string;
|
|
8
|
+
returns: string;
|
|
9
|
+
doc: string;
|
|
10
|
+
/**
|
|
11
|
+
* Index of the argument that names an entity, when the built-in takes one.
|
|
12
|
+
* Drives entity-code completion and the qualify-your-codes diagnostic.
|
|
13
|
+
*/
|
|
14
|
+
entityArg?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const BUILTINS: Builtin[] = [
|
|
18
|
+
// Data operations
|
|
19
|
+
{
|
|
20
|
+
name: "insert",
|
|
21
|
+
signature: "insert(entity, fields)",
|
|
22
|
+
returns: "object",
|
|
23
|
+
entityArg: 0,
|
|
24
|
+
doc: "Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences).",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "select",
|
|
28
|
+
signature: "select(entity, opts?)",
|
|
29
|
+
returns: "object[]",
|
|
30
|
+
entityArg: 0,
|
|
31
|
+
doc: "Structured multi-row read. `opts` = `{columns, filter, orderBy, limit, offset}`. Prefer over `query()` for row-shaped reads.",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "update",
|
|
35
|
+
signature: "update(entity, key, fields)",
|
|
36
|
+
returns: "number",
|
|
37
|
+
entityArg: 0,
|
|
38
|
+
doc: "Update the row(s) matched by `key`; returns rows affected. A value may be `{ inc: n }` to add to the column's current value — use that for every counter, balance and quantity.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "delete",
|
|
42
|
+
signature: "delete(entity, key)",
|
|
43
|
+
returns: "number",
|
|
44
|
+
entityArg: 0,
|
|
45
|
+
doc: "Delete the row(s) matched by `key`; returns rows affected.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "query",
|
|
49
|
+
signature: "query(sql, args?)",
|
|
50
|
+
returns: "object[]",
|
|
51
|
+
doc: "Execute parameterized SQL with `@argName` placeholders and schema-qualified table names. Escape hatch — prefer `select()` / `update()` / `delete()` when the structured shape fits.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "getRecord",
|
|
55
|
+
signature: "getRecord(entity, key)",
|
|
56
|
+
returns: "object",
|
|
57
|
+
entityArg: 0,
|
|
58
|
+
doc: "Fetch a single record by key. **Throws** a localized not-found error when absent.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "getRecordOrNull",
|
|
62
|
+
signature: "getRecordOrNull(entity, key)",
|
|
63
|
+
returns: "object | null",
|
|
64
|
+
entityArg: 0,
|
|
65
|
+
doc: "Like `getRecord`, but returns `null` instead of throwing — for an expected absence, guarded with `if (rec == null)`.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "preloadRef",
|
|
69
|
+
signature: "preloadRef(fkColumn)",
|
|
70
|
+
returns: "object",
|
|
71
|
+
doc: "Load the referenced record for a FK column. Read its fields with `ref.get('field')`.",
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Messaging
|
|
75
|
+
{
|
|
76
|
+
name: "error",
|
|
77
|
+
signature: "error(message)",
|
|
78
|
+
returns: "never",
|
|
79
|
+
doc: "Abort the action and show an error. Rolls back all changes.",
|
|
80
|
+
},
|
|
81
|
+
{ name: "warn", signature: "warn(message)", returns: "void", doc: "Show a warning and continue." },
|
|
82
|
+
{
|
|
83
|
+
name: "info",
|
|
84
|
+
signature: "info(message)",
|
|
85
|
+
returns: "void",
|
|
86
|
+
doc: "Show an informational message — a receipt for work the action did, not a way to publish a computed value.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "exit",
|
|
90
|
+
signature: "exit(message?, level?)",
|
|
91
|
+
returns: "never",
|
|
92
|
+
doc: "Stop the script early **without** error, keeping changes made so far. Level `'info'` (default) or `'warn'`. This is the DSL's early return — the block compiles to a bare script, so a `return` outside a function is a syntax error wherever it sits.",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "notify",
|
|
96
|
+
signature: "notify(userId, message)",
|
|
97
|
+
returns: "void",
|
|
98
|
+
doc: "Send an in-app notification to a specific user.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "sendEmail",
|
|
102
|
+
signature: "sendEmail(to, subjectOrTemplate, dataOrBody?)",
|
|
103
|
+
returns: "void",
|
|
104
|
+
doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>…</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code — so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. There is no fourth argument.",
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// External calls
|
|
108
|
+
{
|
|
109
|
+
name: "callApi",
|
|
110
|
+
signature: "callApi(url, method, headers?, body?)",
|
|
111
|
+
returns: "string",
|
|
112
|
+
doc: "HTTP request to an external API; returns the body as a string. Max 10 calls per script, 120s timeout, 5MB response.",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "callService",
|
|
116
|
+
signature: "callService(name, args)",
|
|
117
|
+
returns: "any",
|
|
118
|
+
doc: "Invoke a registered host service.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "callProc",
|
|
122
|
+
signature: "callProc(procName, args)",
|
|
123
|
+
returns: "any",
|
|
124
|
+
doc: "Call a stored procedure with named parameters.",
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// Files, settings, extraction
|
|
128
|
+
{
|
|
129
|
+
name: "getFileBase64",
|
|
130
|
+
signature: "getFileBase64(fileField)",
|
|
131
|
+
returns: "string",
|
|
132
|
+
doc: "Read a file from storage as base64. Use with `callApi()` to send files to external APIs. Max 10 MB.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "getFileUrl",
|
|
136
|
+
signature: "getFileUrl(fileField)",
|
|
137
|
+
returns: "string",
|
|
138
|
+
doc: "Temporary signed download URL (relative path) for a file field — for emails and notifications, not for `callApi()`.",
|
|
139
|
+
},
|
|
140
|
+
{ name: "getFileInfo", signature: "getFileInfo(fileField)", returns: "object | null", doc: "File metadata for a file field." },
|
|
141
|
+
{
|
|
142
|
+
name: "download",
|
|
143
|
+
signature: "download(url, fileName?)",
|
|
144
|
+
returns: "void",
|
|
145
|
+
doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing — the URL must already serve the bytes, e.g. the `/api/temp-file/{token}` a service handed back. To put a remote file INTO storage, fetch it with `callApi()` and write it to a file column.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "getSetting",
|
|
149
|
+
signature: "getSetting(settingCd)",
|
|
150
|
+
returns: "any",
|
|
151
|
+
doc: "Read a module setting, resolved through the folder chain (current folder → parents → module default).",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "getSecret",
|
|
155
|
+
signature: "getSecret(secretCd)",
|
|
156
|
+
returns: "string",
|
|
157
|
+
doc: "Retrieve a decrypted secret (API key, token). Managed in the admin UI.",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "ocrExtract",
|
|
161
|
+
signature: "ocrExtract(fileField, endpointBaseUrl, schema?, opts?)",
|
|
162
|
+
returns: "string (v1) | object (v2)",
|
|
163
|
+
doc: "Run OCR extraction against a document. **The return type follows the mode, and only `opts.mode` selects it** — a third argument alone never does. Without `opts.mode` (v1) the third argument is forwarded as `hints` and the result is the raw bundle as a JSON **string**: `JSON.parse()` it, or every property read is `undefined`. With `{ mode: 'extract' }` the third argument is a schema object and the result is already parsed — `{ fields, confidence?, unmatched, raw_text, meta }`. `{ mode: 'summary' }` needs no schema and returns `{ summary, raw_text, meta }` parsed. `{ profile: 'module_cd.profile_cd' }` uses a registered extraction profile, implies mode `extract`, and cannot be combined with an inline schema. The OCR host must be on `Dsl:Limits:AllowedHosts`.",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "detectDocument",
|
|
167
|
+
signature: "detectDocument(rawText)",
|
|
168
|
+
returns: "object | null",
|
|
169
|
+
doc: "Score text against reachable extraction profiles; returns the best match `{ profile, docType, score }` or `null`.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "applyProfile",
|
|
173
|
+
signature: "applyProfile(json, profile)",
|
|
174
|
+
returns: "object",
|
|
175
|
+
doc: "Map already-extracted JSON to target fields and return the mapped object — it never writes, so the `insert()` / `update()` stays yours. `json` is the parsed extractor output (`JSON.parse(ocrExtract(...))`). `profile` must be an **inline object**, `{ map: { target: { from, transform?, pick? } }, lines?: { from, map, key? } }` — passing a profile code throws, as profile-by-code is a later slice. `from` is a path into the extraction; `transform` is one of `firstValid:<basis>`, `lookupRef:<entity.matchField>>returnField>`, `matchCatalog:<same>`, `parseMoney` or `parseDate`. Header targets come back at the top level and the 1:N block under its `key` (default `lines`); a missing source path omits the target. Not the same thing as `ocrExtract`'s registered `opts.profile` code.",
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// Utilities
|
|
179
|
+
{ name: "now", signature: "now()", returns: "datetime", doc: "Current date/time. Lowercase — this is the execute-block date function." },
|
|
180
|
+
{ name: "addDays", signature: "addDays(date, n)", returns: "date", doc: "Add `n` days to a date." },
|
|
181
|
+
{ name: "addMinutes", signature: "addMinutes(date, n)", returns: "datetime", doc: "Add `n` minutes." },
|
|
182
|
+
{ name: "addSeconds", signature: "addSeconds(date, n)", returns: "datetime", doc: "Add `n` seconds." },
|
|
183
|
+
{ name: "currentUserId", signature: "currentUserId()", returns: "long", doc: "The acting user's tenant user ID." },
|
|
184
|
+
{ name: "flush", signature: "flush()", returns: "void", doc: "Flush pending writes so a following read sees them." },
|
|
185
|
+
{ name: "tryParseJson", signature: "tryParseJson(value)", returns: "any | null", doc: "Parse JSON, returning `null` on failure instead of throwing." },
|
|
186
|
+
{
|
|
187
|
+
name: "nextNumber",
|
|
188
|
+
signature: "nextNumber(entity)",
|
|
189
|
+
returns: "string",
|
|
190
|
+
entityArg: 0,
|
|
191
|
+
doc: "Generate the next value from a number sequence. Usually unnecessary — the platform auto-fills on `insert()`.",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "entityLink",
|
|
195
|
+
signature: "entityLink(entityCd, record, description?)",
|
|
196
|
+
returns: "link",
|
|
197
|
+
entityArg: 0,
|
|
198
|
+
doc: "Build a clickable link to a record, for an `entitylink` column. Always pass a **qualified** code — `entity_cd` is only unique per module.",
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
export const BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.name, b]));
|
|
203
|
+
|
|
204
|
+
/** Bare identifiers the runtime injects — no parentheses. */
|
|
205
|
+
export const BUILTIN_VALUES: Record<string, string> = {
|
|
206
|
+
userId: "The acting user's tenant user ID. A bare identifier — `userId()` is a compile error.",
|
|
207
|
+
records: "The selected records, in a `batch` action. Iterate with `for x in records { … }`.",
|
|
208
|
+
params: "Declared action parameters — read with `params[name]`.",
|
|
209
|
+
old: "Pre-change values, in a trigger-invoked action — read with `old[field]`. Read-only.",
|
|
210
|
+
};
|
package/src/dsl/check.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// Static checks for an action DSL body (`logic/actions/*.dsl`).
|
|
2
|
+
//
|
|
3
|
+
// Every rule here mirrors something the platform only tells you at pack or
|
|
4
|
+
// install time — a slow, tenant-bound round trip. The point is to move that
|
|
5
|
+
// feedback to where the script is written, and to the validator that runs
|
|
6
|
+
// before a pack, from one implementation rather than two.
|
|
7
|
+
//
|
|
8
|
+
// The rules that need to know about the module take it through `DslContext`,
|
|
9
|
+
// which is small on purpose: a module code, the action, and the columns of the
|
|
10
|
+
// entity behind the record context. Anything a host can't resolve it simply
|
|
11
|
+
// omits, and the rules that depend on it stand down. See ./types.
|
|
12
|
+
|
|
13
|
+
import { BUILTIN_BY_NAME } from "./builtins";
|
|
14
|
+
import { CONTROL_KEYWORDS, type Token } from "./lexer";
|
|
15
|
+
import { type DslDocument, type Span, blockLabelAt, parseDsl } from "./parse";
|
|
16
|
+
import type { ColumnLookup, DslContext, DslIssue, DslSeverity } from "./types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check a DSL body. Returns [] for a clean script.
|
|
20
|
+
*
|
|
21
|
+
* Issues come back in source-feature order (field reads, params, execution
|
|
22
|
+
* mode, block spelling, built-ins, top-level returns, inline assignments)
|
|
23
|
+
* rather than sorted by position — a host that wants them in file order can
|
|
24
|
+
* sort on `start`.
|
|
25
|
+
*/
|
|
26
|
+
export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
27
|
+
const parsed = parseDsl(text);
|
|
28
|
+
const out: DslIssue[] = [];
|
|
29
|
+
const positionAt = lineIndexer(text);
|
|
30
|
+
|
|
31
|
+
const add = (
|
|
32
|
+
span: Span,
|
|
33
|
+
message: string,
|
|
34
|
+
severity: DslSeverity,
|
|
35
|
+
rule: string,
|
|
36
|
+
) => {
|
|
37
|
+
const { line, column } = positionAt(span.start);
|
|
38
|
+
out.push({
|
|
39
|
+
rule,
|
|
40
|
+
severity,
|
|
41
|
+
message,
|
|
42
|
+
start: span.start,
|
|
43
|
+
end: span.end,
|
|
44
|
+
line,
|
|
45
|
+
column,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const action = ctx.action;
|
|
50
|
+
const entity = ctx.currentEntity ?? null;
|
|
51
|
+
const isBatch = (action?.executionMode ?? "single") === "batch";
|
|
52
|
+
const hasColumn = columnMatcher(entity?.columns);
|
|
53
|
+
|
|
54
|
+
// ── record fields ────────────────────────────────────────────────
|
|
55
|
+
for (const ref of parsed.fieldRefs) {
|
|
56
|
+
// Neither block is rewritten by TransformLine, so brackets in them are
|
|
57
|
+
// data rather than field reads: `schema:` is embedded as a JS object
|
|
58
|
+
// literal, and `params:` is read by RxParamDecl — whose trailing
|
|
59
|
+
// key=value pairs take a bracketed option list, `options=[cash]`.
|
|
60
|
+
if (ref.block === "schema" || ref.block === "params") continue;
|
|
61
|
+
|
|
62
|
+
// Single-hop is the DSL's own rewrite (`RxRefNav`), so the rule belongs
|
|
63
|
+
// to the blocks that go through it. canExecute: reaches the formula
|
|
64
|
+
// engine verbatim, and a formula walks a chain of any depth —
|
|
65
|
+
// `[vehicle].[depot].[city_name]` resolves there and is tested.
|
|
66
|
+
const isDslBlock = ref.block === "execute" || ref.block === "onBeforeStart";
|
|
67
|
+
// The compiler rewrites the first hop and then fails on a surviving
|
|
68
|
+
// `.[` — so it is the bracket, not the hop count, that marks a second
|
|
69
|
+
// reference. `[vehicle].plate.length` compiles to
|
|
70
|
+
// `__ref_vehicle.get('plate').length`: one hop, then JavaScript.
|
|
71
|
+
const secondRef = ref.navigation.findIndex((h, idx) => idx > 0 && h.bracketed);
|
|
72
|
+
if (isDslBlock && secondRef > 0) {
|
|
73
|
+
const first = ref.navigation[secondRef]!;
|
|
74
|
+
const last = ref.navigation[ref.navigation.length - 1]!;
|
|
75
|
+
add(
|
|
76
|
+
{ start: first.span.start, end: last.span.end },
|
|
77
|
+
`Ref navigation is single-hop in the DSL. Use getRecord() or select() to reach past "${ref.navigation[0]!.name}".`,
|
|
78
|
+
"error",
|
|
79
|
+
"dsl/multi-hop-nav",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Batch execute: applies no `[field]` rewrite at all, so `[true]` stays
|
|
84
|
+
// the literal it is — and a keyword is never a column name anyway.
|
|
85
|
+
if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
|
|
86
|
+
continue;
|
|
87
|
+
|
|
88
|
+
if (!entity) continue;
|
|
89
|
+
if (entity.columns.size === 0) continue;
|
|
90
|
+
if (hasColumn(ref.name)) continue;
|
|
91
|
+
// `var ids = [orderId]` is an array literal, not a field read. Only the
|
|
92
|
+
// name tells the two apart, so a name bound in this script stands down —
|
|
93
|
+
// the same fail-open the entity checks above use.
|
|
94
|
+
//
|
|
95
|
+
// Only the bare bracket is ambiguous. `r[stauts]` and
|
|
96
|
+
// `records[0][stauts]` name a column outright — the compiler rewrites
|
|
97
|
+
// each to `.get('stauts')` with the literal name — so a same-named
|
|
98
|
+
// local says nothing about them.
|
|
99
|
+
if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
|
|
100
|
+
|
|
101
|
+
add(
|
|
102
|
+
ref.span,
|
|
103
|
+
`"${ref.name}" is not a column on ${entity.qualified}. [field] names are checked at pack time — this is a compile error, not an empty read.`,
|
|
104
|
+
"error",
|
|
105
|
+
"dsl/unknown-column",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `old[field]` names columns of the same entity — RxOldAccess feeds them
|
|
110
|
+
// into the very same check — so a typo there is a compile error too.
|
|
111
|
+
if (entity && entity.columns.size > 0) {
|
|
112
|
+
for (const ref of parsed.globalRefs) {
|
|
113
|
+
if (ref.global !== "old") continue;
|
|
114
|
+
if (ref.block !== "execute" && ref.block !== "onBeforeStart") continue;
|
|
115
|
+
if (hasColumn(ref.property)) continue;
|
|
116
|
+
add(
|
|
117
|
+
ref.span,
|
|
118
|
+
`"${ref.property}" is not a column on ${entity.qualified}. old[field] reads the pre-change value of a column, and the name is checked at pack time.`,
|
|
119
|
+
"error",
|
|
120
|
+
"dsl/unknown-column",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── params ───────────────────────────────────────────────────────
|
|
126
|
+
// `ValidateParamUsages` compares with OrdinalIgnoreCase, so `params[Qty]`
|
|
127
|
+
// against a declared `qty` compiles — flagging it would be a false error.
|
|
128
|
+
const declared = new Set(parsed.params.map((p) => p.name.toLowerCase()));
|
|
129
|
+
const hasParamsBlock = parsed.blocks.some((b) => b.kind === "params");
|
|
130
|
+
for (const ref of parsed.globalRefs) {
|
|
131
|
+
if (ref.global !== "params") continue;
|
|
132
|
+
if (!hasParamsBlock || declared.has(ref.property.toLowerCase())) continue;
|
|
133
|
+
add(
|
|
134
|
+
ref.span,
|
|
135
|
+
`"${ref.property}" is not declared in the params: block.`,
|
|
136
|
+
"error",
|
|
137
|
+
"dsl/unknown-param",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── execution mode ───────────────────────────────────────────────
|
|
142
|
+
if (action) {
|
|
143
|
+
const mode = action.executionMode ?? "single";
|
|
144
|
+
// `recordsRefs` covers both `records[x]` and the bare `for x in records`,
|
|
145
|
+
// and comes from the lexer, so the word inside a string or a comment
|
|
146
|
+
// does not count — nor does a script that binds its own `records`,
|
|
147
|
+
// which shadows the batch global and says nothing about the mode.
|
|
148
|
+
const recordsSpan = parsed.locals.has("records")
|
|
149
|
+
? undefined
|
|
150
|
+
: parsed.recordsRefs[0];
|
|
151
|
+
// execute: alone. `onBeforeStart:` is compiled by CompilePerRecordBlock
|
|
152
|
+
// whatever the mode — "always per-record (no batch form)" — so a bare
|
|
153
|
+
// field is exactly how it is meant to be written there.
|
|
154
|
+
//
|
|
155
|
+
// Batch mode never applies the `[field]` rewrite at all, which is why
|
|
156
|
+
// `var ids = [id]` survives as the array literal it looks like. Same
|
|
157
|
+
// disambiguation as the column rule: a name bound in this script is a
|
|
158
|
+
// local, not a column.
|
|
159
|
+
const bareFields = parsed.fieldRefs.filter(
|
|
160
|
+
(r) =>
|
|
161
|
+
r.block === "execute" &&
|
|
162
|
+
r.isCurrentRecord &&
|
|
163
|
+
!parsed.locals.has(r.name) &&
|
|
164
|
+
!LITERAL_NAMES.has(r.name),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (mode === "batch") {
|
|
168
|
+
// Every one of them: an editor underlines what it is told to, and
|
|
169
|
+
// reporting only the first hides the rest behind a re-check.
|
|
170
|
+
for (const field of bareFields) {
|
|
171
|
+
add(
|
|
172
|
+
field.outerSpan,
|
|
173
|
+
`Action "${action.code}" runs in batch mode, which has no current record. Iterate instead: for x in records { x[${field.name}] }.`,
|
|
174
|
+
"error",
|
|
175
|
+
"dsl/batch-bare-field",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (mode !== "batch" && recordsSpan) {
|
|
180
|
+
add(
|
|
181
|
+
recordsSpan,
|
|
182
|
+
`"records" only exists in batch mode; action "${action.code}" runs in "${mode}" mode.`,
|
|
183
|
+
"warning",
|
|
184
|
+
"dsl/records-outside-batch",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── block-specific spelling ──────────────────────────────────────
|
|
190
|
+
// canExecute is parsed by the formula engine, execute by the JavaScript
|
|
191
|
+
// compiler. They share more vocabulary than the docs suggest — NULL and
|
|
192
|
+
// lowercase booleans work in both, and shipped modules rely on that — so
|
|
193
|
+
// only the operators that genuinely have no formula spelling are flagged.
|
|
194
|
+
for (const token of parsed.tokens) {
|
|
195
|
+
if (blockOf(parsed, token.start) !== "canExecute") continue;
|
|
196
|
+
if (token.text !== "&&" && token.text !== "||") continue;
|
|
197
|
+
add(
|
|
198
|
+
{ start: token.start, end: token.end },
|
|
199
|
+
`canExecute is a formula, not JavaScript — write ${token.text === "&&" ? "AND" : "OR"}.`,
|
|
200
|
+
"warning",
|
|
201
|
+
"dsl/js-in-formula",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── built-in misuse ──────────────────────────────────────────────
|
|
206
|
+
for (const call of parsed.calls) {
|
|
207
|
+
if (call.name === "userId") {
|
|
208
|
+
add(
|
|
209
|
+
call.nameSpan,
|
|
210
|
+
"userId is a bare identifier — userId() is a compile error.",
|
|
211
|
+
"error",
|
|
212
|
+
"dsl/user-id-call",
|
|
213
|
+
);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const builtin = BUILTIN_BY_NAME.get(call.name);
|
|
218
|
+
if (!builtin || builtin.entityArg === undefined) continue;
|
|
219
|
+
const arg = call.firstStringArg;
|
|
220
|
+
if (!arg) continue;
|
|
221
|
+
|
|
222
|
+
// A hyphenated module code can't be schema-qualified, so the rule is
|
|
223
|
+
// skipped there rather than reported as unfixable.
|
|
224
|
+
if (arg.value.includes(".") || ctx.moduleCode?.includes("-")) continue;
|
|
225
|
+
add(
|
|
226
|
+
arg.span,
|
|
227
|
+
`Qualify the entity code as "${ctx.moduleCode ? `${ctx.moduleCode}.` : "<module>."}${arg.value}" — entity_cd is only unique per module, so a bare code can resolve to another module's entity.`,
|
|
228
|
+
"info",
|
|
229
|
+
"dsl/unqualified-entity",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── indented headers, and returns outside a function ─────────────
|
|
234
|
+
// An indented `params:` inside an object literal is an ordinary key, which
|
|
235
|
+
// is the whole reason the compiler anchors headers at column 0 rather than
|
|
236
|
+
// trusting the line — so that rule reads brace depth.
|
|
237
|
+
//
|
|
238
|
+
// `return` reads function scope instead. Both blocks compile to a bare
|
|
239
|
+
// script, and Esprima's ParseScript rejects a return outside a function
|
|
240
|
+
// wherever it sits: `if (x) { return }` and `if (x) return` fail exactly as
|
|
241
|
+
// a return on its own line does.
|
|
242
|
+
let depth = 0;
|
|
243
|
+
const functionDepths: number[] = [];
|
|
244
|
+
// Index of the `(` that the most recent `)` closed. Tracked with a stack
|
|
245
|
+
// because the nearest open paren is not the matching one: in `if (now()) {`
|
|
246
|
+
// it is `now`'s, and in `function f(x = (1)) {` it is the default value's.
|
|
247
|
+
const parenStack: number[] = [];
|
|
248
|
+
let matchingParenOpen = -1;
|
|
249
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
250
|
+
const token = parsed.tokens[i]!;
|
|
251
|
+
|
|
252
|
+
if (token.text === "(") parenStack.push(i);
|
|
253
|
+
else if (token.text === ")") matchingParenOpen = parenStack.pop() ?? -1;
|
|
254
|
+
|
|
255
|
+
if (token.text === "{") {
|
|
256
|
+
depth++;
|
|
257
|
+
if (opensFunctionBody(parsed.tokens, i, matchingParenOpen))
|
|
258
|
+
functionDepths.push(depth);
|
|
259
|
+
} else if (token.text === "}") {
|
|
260
|
+
if (functionDepths[functionDepths.length - 1] === depth)
|
|
261
|
+
functionDepths.pop();
|
|
262
|
+
depth = Math.max(0, depth - 1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (depth === 0 && token.character > 0) {
|
|
266
|
+
const kind = blockLabelAt(parsed.tokens, i);
|
|
267
|
+
// `execute: number` inside params: declares a param called execute.
|
|
268
|
+
// RxParamDecl takes any `\w+`, so a block name is a legal param
|
|
269
|
+
// name — and a declaration the parser already read is not a header.
|
|
270
|
+
const isParamDecl = parsed.params.some(
|
|
271
|
+
(p) => p.nameSpan.start === token.start,
|
|
272
|
+
);
|
|
273
|
+
if (kind && !isParamDecl) {
|
|
274
|
+
add(
|
|
275
|
+
{ start: token.start, end: parsed.tokens[i + 1]!.end },
|
|
276
|
+
`Block headers start at column 0. Indented, "${kind}:" is body text — the compiler never opens the block, and it runs as if empty.`,
|
|
277
|
+
"error",
|
|
278
|
+
"dsl/indented-block-header",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (token.kind !== "ident" || token.text !== "return") continue;
|
|
284
|
+
// `return` is a reserved word, so it is a statement unless it is being
|
|
285
|
+
// used as a name: `result.return` reads a property, `{ return: 1 }`
|
|
286
|
+
// declares a key and `{ return() { … } }` declares a method. All three
|
|
287
|
+
// are legal JavaScript.
|
|
288
|
+
if (parsed.tokens[i - 1]?.text === ".") continue;
|
|
289
|
+
if (parsed.tokens[i + 1]?.text === ":") continue;
|
|
290
|
+
if (isMethodName(parsed.tokens, i)) continue;
|
|
291
|
+
if (functionDepths.length > 0) continue;
|
|
292
|
+
const block = blockOf(parsed, token.start);
|
|
293
|
+
if (block !== "execute" && block !== "onBeforeStart") continue;
|
|
294
|
+
add(
|
|
295
|
+
{ start: token.start, end: token.end },
|
|
296
|
+
`A return outside a function is a syntax error — ${block}: compiles to a bare script. Use exit(message, level) to stop early and keep the changes made so far.`,
|
|
297
|
+
"error",
|
|
298
|
+
"dsl/top-level-return",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
out.push(...inlineAssignmentIssues(parsed, positionAt));
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Column matching is case-insensitive at the other end — `EntityColumnLookup`
|
|
308
|
+
* hands the compiler an OrdinalIgnoreCase set — but a host passes us whatever
|
|
309
|
+
* `Set` or `Map` it already holds, and a mixed-case column name is legal.
|
|
310
|
+
*
|
|
311
|
+
* So fold both sides: the names once per check when the lookup can enumerate
|
|
312
|
+
* them (`Set` and `Map` both can), and the reference on the way in. A lookup
|
|
313
|
+
* that can do neither falls back to trying the reference in lower case, which
|
|
314
|
+
* is all that is left to try.
|
|
315
|
+
*/
|
|
316
|
+
function columnMatcher(columns?: ColumnLookup): (name: string) => boolean {
|
|
317
|
+
if (!columns) return () => true;
|
|
318
|
+
if (typeof columns.keys !== "function")
|
|
319
|
+
return (name) => columns.has(name) || columns.has(name.toLowerCase());
|
|
320
|
+
|
|
321
|
+
const folded = new Set<string>();
|
|
322
|
+
for (const key of columns.keys()) folded.add(key.toLowerCase());
|
|
323
|
+
return (name) => folded.has(name.toLowerCase());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Whether the name at `i` heads a method shorthand — `return() { … }`, whose
|
|
328
|
+
* parameter list is followed by a body. `return (x + 1)` is a statement with a
|
|
329
|
+
* parenthesized expression and has no brace after the closing paren, so the
|
|
330
|
+
* two are told apart by what follows the parens rather than by what opens them.
|
|
331
|
+
*/
|
|
332
|
+
function isMethodName(tokens: Token[], i: number): boolean {
|
|
333
|
+
if (tokens[i + 1]?.text !== "(") return false;
|
|
334
|
+
let depth = 0;
|
|
335
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
336
|
+
const text = tokens[j]!.text;
|
|
337
|
+
if (text === "(") depth++;
|
|
338
|
+
else if (text === ")") {
|
|
339
|
+
depth--;
|
|
340
|
+
if (depth === 0) return tokens[j + 1]?.text === "{";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Whether the `{` at `i` opens a function body — where a `return` is legal.
|
|
348
|
+
*
|
|
349
|
+
* `=> {` is one. So is `) {` when the name in front of the matching `(` is not
|
|
350
|
+
* a control keyword: that covers `function (x) {`, `function f(x) {` and the
|
|
351
|
+
* `f() {` method shorthand, while leaving `if (x) {` and `for (…) {` alone.
|
|
352
|
+
*/
|
|
353
|
+
function opensFunctionBody(
|
|
354
|
+
tokens: Token[],
|
|
355
|
+
i: number,
|
|
356
|
+
matchingParenOpen: number,
|
|
357
|
+
): boolean {
|
|
358
|
+
const prev = tokens[i - 1];
|
|
359
|
+
if (prev?.text === "=>") return true;
|
|
360
|
+
if (prev?.text !== ")" || matchingParenOpen < 0) return false;
|
|
361
|
+
const head = tokens[matchingParenOpen - 1];
|
|
362
|
+
return head?.kind === "ident" && !CONTROL_KEYWORDS.has(head.text);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Keyword literals, which `[…]` can only be holding as array elements. Both
|
|
367
|
+
* spellings: the DSL takes SQL-style `NULL`/`TRUE`/`FALSE` as well as the
|
|
368
|
+
* JavaScript ones, and rewrites them to the latter.
|
|
369
|
+
*/
|
|
370
|
+
const LITERAL_NAMES = new Set([
|
|
371
|
+
"true",
|
|
372
|
+
"false",
|
|
373
|
+
"null",
|
|
374
|
+
"TRUE",
|
|
375
|
+
"FALSE",
|
|
376
|
+
"NULL",
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
function blockOf(parsed: DslDocument, offset: number) {
|
|
380
|
+
return (
|
|
381
|
+
parsed.blocks.find((b) => offset >= b.body.start && offset < b.body.end)
|
|
382
|
+
?.kind ?? null
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* `if (…) { [field] = x }` on a single line mis-compiles — the assignment is
|
|
388
|
+
* rewritten in place and the setter call is lost. One assignment per line.
|
|
389
|
+
*/
|
|
390
|
+
function inlineAssignmentIssues(
|
|
391
|
+
parsed: DslDocument,
|
|
392
|
+
positionAt: (offset: number) => { line: number; column: number },
|
|
393
|
+
): DslIssue[] {
|
|
394
|
+
const out: DslIssue[] = [];
|
|
395
|
+
for (const ref of parsed.fieldRefs) {
|
|
396
|
+
// `ref.endIndex` is where the parser stopped: a `.prop` hop is 2 tokens
|
|
397
|
+
// but a `.[prop]` hop is 4, so the count cannot be derived here.
|
|
398
|
+
const assign = parsed.tokens[ref.endIndex + 1];
|
|
399
|
+
if (!assign || assign.kind !== "punct" || assign.text !== "=") continue;
|
|
400
|
+
|
|
401
|
+
// Walk back over this line only, looking for a brace that opened here.
|
|
402
|
+
const line = parsed.tokens[ref.startIndex]!.line;
|
|
403
|
+
let openedHere = false;
|
|
404
|
+
for (let i = ref.startIndex - 1; i >= 0; i--) {
|
|
405
|
+
const t = parsed.tokens[i]!;
|
|
406
|
+
if (t.line !== line) break;
|
|
407
|
+
if (t.text === "{") {
|
|
408
|
+
openedHere = true;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!openedHere) continue;
|
|
413
|
+
|
|
414
|
+
const pos = positionAt(ref.outerSpan.start);
|
|
415
|
+
out.push({
|
|
416
|
+
rule: "dsl/inline-assignment",
|
|
417
|
+
severity: "warning",
|
|
418
|
+
message:
|
|
419
|
+
"An assignment to a record field on the same line as the brace that opens its block mis-compiles. Put the assignment on its own line.",
|
|
420
|
+
start: ref.outerSpan.start,
|
|
421
|
+
end: ref.outerSpan.end,
|
|
422
|
+
line: pos.line,
|
|
423
|
+
column: pos.column,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Offset → 1-indexed line/column, over a line-start table built once per check.
|
|
431
|
+
* Scanning the prefix per issue is O(n) each, and a body with many issues is
|
|
432
|
+
* exactly the one where that shows.
|
|
433
|
+
*/
|
|
434
|
+
function lineIndexer(text: string): (offset: number) => { line: number; column: number } {
|
|
435
|
+
const starts = [0];
|
|
436
|
+
for (let i = 0; i < text.length; i++) {
|
|
437
|
+
if (text[i] === "\n") starts.push(i + 1);
|
|
438
|
+
}
|
|
439
|
+
return (offset: number) => {
|
|
440
|
+
// Binary search for the last line start at or before `offset`.
|
|
441
|
+
let lo = 0;
|
|
442
|
+
let hi = starts.length - 1;
|
|
443
|
+
while (lo < hi) {
|
|
444
|
+
const mid = (lo + hi + 1) >> 1;
|
|
445
|
+
if (starts[mid]! <= offset) lo = mid;
|
|
446
|
+
else hi = mid - 1;
|
|
447
|
+
}
|
|
448
|
+
return { line: lo + 1, column: offset - starts[lo]! + 1 };
|
|
449
|
+
};
|
|
450
|
+
}
|