@dforge-core/dforge-mcp 0.2.22 → 0.2.24

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/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.17/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.19/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31283,7 +31283,7 @@ function buildZedTasks() {
31283
31283
  var fs = __toESM(require("fs"));
31284
31284
  var path = __toESM(require("path"));
31285
31285
 
31286
- // node_modules/.pnpm/@dforge-core+metadata@0.0.22/node_modules/@dforge-core/metadata/dist/index.js
31286
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.27/node_modules/@dforge-core/metadata/dist/index.js
31287
31287
  var AggType = {
31288
31288
  Sum: "sum",
31289
31289
  Count: "count",
@@ -31435,30 +31435,68 @@ function applyTemplate(value, entity) {
31435
31435
  const local = entity.split(".").pop() ?? entity;
31436
31436
  return value.replace(/\{entity\}/g, local).replace(/\{Entity\}/g, titleCaseEntity(entity));
31437
31437
  }
31438
- function templateFields(fields, entity) {
31439
- const out = {};
31440
- for (const [name, def] of Object.entries(fields)) {
31441
- const key = applyTemplate(name, entity);
31442
- const next = { ...def };
31443
- if (typeof next.description === "string") next.description = applyTemplate(next.description, entity);
31444
- out[key] = next;
31438
+ function templateField(def, entity) {
31439
+ const next = { ...def };
31440
+ if (typeof next.description === "string") next.description = applyTemplate(next.description, entity);
31441
+ return next;
31442
+ }
31443
+ function emptyFields() {
31444
+ return /* @__PURE__ */ Object.create(null);
31445
+ }
31446
+ function typeKeyOf(def) {
31447
+ return def.columnType ?? def.dbDatatype ?? def.fieldTypeCd;
31448
+ }
31449
+ function addTraitField(sink, key, def) {
31450
+ const existing = sink.fields[key];
31451
+ if (!Object.hasOwn(sink.fields, key)) {
31452
+ sink.fields[key] = def;
31453
+ sink.origin.set(key, "trait");
31454
+ return;
31455
+ }
31456
+ if (sink.conflicts && !sink.conflicts.some((c) => c.field === key)) {
31457
+ const existingType = typeKeyOf(existing);
31458
+ const traitType = typeKeyOf(def);
31459
+ if (existingType !== traitType) {
31460
+ sink.conflicts.push({
31461
+ field: key,
31462
+ existingFrom: sink.origin.get(key) ?? "trait",
31463
+ existingType,
31464
+ traitType
31465
+ });
31466
+ }
31445
31467
  }
31446
- return out;
31447
31468
  }
31448
- function expandTrait(traitCd, entityName) {
31449
- const trait = byCd3.get(traitCd);
31450
- if (!trait) return {};
31451
- const result = {};
31452
- for (const included of trait.includes ?? []) {
31453
- Object.assign(result, expandTrait(included, entityName));
31469
+ function expandTraitInner(traitCd, entityName, localTraits, path18, sink) {
31470
+ if (path18.has(traitCd)) return;
31471
+ path18.add(traitCd);
31472
+ const trait = localTraits && Object.hasOwn(localTraits, traitCd) ? localTraits[traitCd] : byCd3.get(traitCd);
31473
+ if (trait) {
31474
+ for (const included of trait.includes ?? []) {
31475
+ expandTraitInner(included, entityName, localTraits, path18, sink);
31476
+ }
31477
+ for (const [name, def] of Object.entries(trait.fields ?? {})) {
31478
+ addTraitField(sink, applyTemplate(name, entityName), templateField(def, entityName));
31479
+ }
31454
31480
  }
31455
- Object.assign(result, templateFields(trait.fields, entityName));
31456
- return result;
31481
+ path18.delete(traitCd);
31457
31482
  }
31458
- function expandTraits(traitCds, entityName) {
31459
- const result = {};
31460
- for (const cd of traitCds) Object.assign(result, expandTrait(cd, entityName));
31461
- return result;
31483
+ function expandTraits(traitCds, entityName, localTraits) {
31484
+ const sink = { fields: emptyFields(), origin: /* @__PURE__ */ new Map() };
31485
+ for (const cd of traitCds) {
31486
+ expandTraitInner(cd, entityName, localTraits, /* @__PURE__ */ new Set(), sink);
31487
+ }
31488
+ return sink.fields;
31489
+ }
31490
+ function traitFieldConflicts(traitCds, entityName, opts = {}) {
31491
+ const sink = { fields: emptyFields(), origin: /* @__PURE__ */ new Map(), conflicts: [] };
31492
+ for (const [key, def] of Object.entries(opts.fields ?? {})) {
31493
+ sink.fields[key] = def;
31494
+ sink.origin.set(key, "field");
31495
+ }
31496
+ for (const cd of traitCds) {
31497
+ expandTraitInner(cd, entityName, opts.localTraits, /* @__PURE__ */ new Set(), sink);
31498
+ }
31499
+ return sink.conflicts;
31462
31500
  }
31463
31501
  var NUMERIC_TOTAL = 18;
31464
31502
  var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "markdown", "richtext", "code"]);
@@ -31715,14 +31753,107 @@ var traitsInput = external_exports.array(external_exports.string()).default(["id
31715
31753
  }).describe(
31716
31754
  "Entity trait codes \u2014 identity, audit, audit-full, soft-delete, sorting, postable, accumulation, ledger, period. 'identity' makes the PK '{entity}_id'. Traits expand into columns server-side at install; list only the codes."
31717
31755
  );
31718
- function unknownTraits(traitCodes) {
31719
- return traitCodes.filter((cd) => !TRAIT_CODE_SET.has(cd));
31756
+ function unknownTraits(traitCodes, localTraits) {
31757
+ return traitCodes.filter(
31758
+ (cd) => !TRAIT_CODE_SET.has(cd) && localTraits?.[cd] === void 0
31759
+ );
31760
+ }
31761
+ var TRAIT_DEF_KEYS = ["cd", "description", "includes", "fields", "references", "constraints"];
31762
+ var TRAIT_DEF_KEY_SET = new Set(TRAIT_DEF_KEYS);
31763
+ var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
31764
+ function traitsShapeError(parsed) {
31765
+ if (!isPlainObject3(parsed)) {
31766
+ const got = Array.isArray(parsed) ? "an array" : parsed === null ? "null" : `a ${typeof parsed}`;
31767
+ return `not a traits file: expected an object keyed by trait code, got ${got}.`;
31768
+ }
31769
+ for (const [cd, def] of Object.entries(parsed)) {
31770
+ if (!isPlainObject3(def)) {
31771
+ const got = Array.isArray(def) ? "an array" : def === null ? "null" : `a ${typeof def}`;
31772
+ return `not a traits file: trait '${cd}' is ${got}, not a definition object.`;
31773
+ }
31774
+ const unexpected = Object.keys(def).filter((k) => !TRAIT_DEF_KEY_SET.has(k));
31775
+ if (unexpected.length > 0) {
31776
+ if (cd === "traits" && Object.keys(parsed).length === 1) {
31777
+ return `not a traits file: the top level is keyed by trait code, so a "traits" wrapper registers one trait called 'traits' and hides the ${unexpected.length} real one(s) (${unexpected.join(", ")}). Remove the wrapper and put the definitions at the top level.`;
31778
+ }
31779
+ return `not a traits file: trait '${cd}' has unexpected key(s): ${unexpected.join(", ")}. A trait definition carries ${TRAIT_DEF_KEYS.join(", ")}.`;
31780
+ }
31781
+ if (def.fields !== void 0 && !isPlainObject3(def.fields)) {
31782
+ return `not a traits file: trait '${cd}' has a 'fields' that is not an object keyed by column code.`;
31783
+ }
31784
+ const includes = def.includes;
31785
+ if (includes !== void 0 && !(Array.isArray(includes) && includes.every((i) => typeof i === "string"))) {
31786
+ return `not a traits file: trait '${cd}' has an 'includes' that is not an array of trait codes.`;
31787
+ }
31788
+ }
31789
+ return void 0;
31790
+ }
31791
+ function readLocalTraits(moduleRoot) {
31792
+ const file2 = path.join(moduleRoot, "traits.json");
31793
+ if (!fs.existsSync(file2)) return {};
31794
+ let parsed;
31795
+ try {
31796
+ parsed = JSON.parse(fs.readFileSync(file2, "utf8"));
31797
+ } catch (ex) {
31798
+ return { error: `invalid JSON: ${ex.message}.` };
31799
+ }
31800
+ const shape = traitsShapeError(parsed);
31801
+ if (shape) return { error: shape };
31802
+ return { traits: parsed };
31803
+ }
31804
+ function expandedEntity(entity, entityName, localTraits) {
31805
+ const fields = entity.fields ?? {};
31806
+ const traitCodes = entity.traits ?? [];
31807
+ const traitFields = expandTraits(traitCodes, entityName, localTraits);
31808
+ return {
31809
+ columns: /* @__PURE__ */ new Set([...Object.keys(fields), ...Object.keys(traitFields)]),
31810
+ fieldDefs: { ...traitFields, ...fields },
31811
+ // The expansion above is total — a trait field the entity already has
31812
+ // under a different type is merged away here, while the installer keeps
31813
+ // the first and refuses the entity outright. Ask for those collisions
31814
+ // separately, or they are invisible to every caller.
31815
+ conflicts: traitFieldConflicts(traitCodes, entityName, {
31816
+ localTraits,
31817
+ fields
31818
+ })
31819
+ };
31820
+ }
31821
+ function describeTraitConflict(c) {
31822
+ const kept = c.existingFrom === "field" ? "an authored field" : "another trait's field";
31823
+ return `field '${c.field}' is contributed by a trait but already exists as ${kept} with a different type (${c.existingType ?? "untyped"} vs ${c.traitType ?? "untyped"}). The installer keeps the first and refuses the entity \u2014 rename the column, or drop the trait that brings it.`;
31824
+ }
31825
+ function localEntityCode(code2, moduleCode) {
31826
+ if (!code2) return void 0;
31827
+ const dot = code2.indexOf(".");
31828
+ if (dot < 0) return code2;
31829
+ return code2.slice(0, dot) === moduleCode ? code2.slice(dot + 1) : void 0;
31830
+ }
31831
+ function entityRecordContext(paths, manifest, entityCode, localTraits) {
31832
+ const local = localEntityCode(entityCode, manifest.code);
31833
+ if (!local) return void 0;
31834
+ const relPath = (manifest.entities ?? {})[local];
31835
+ if (!relPath) return void 0;
31836
+ const abs = path.join(paths.root, relPath.replace(/^\.\//, ""));
31837
+ if (!fs.existsSync(abs)) return void 0;
31838
+ let entity;
31839
+ try {
31840
+ entity = JSON.parse(fs.readFileSync(abs, "utf8"));
31841
+ } catch {
31842
+ return void 0;
31843
+ }
31844
+ const traitCodes = entity.traits ?? [];
31845
+ if (unknownTraits(traitCodes, localTraits).length > 0) return void 0;
31846
+ return {
31847
+ qualified: `${manifest.code}.${local}`,
31848
+ columns: expandedEntity(entity, local, localTraits).columns
31849
+ };
31720
31850
  }
31721
- function assertKnownTraits(traitCodes, entityName) {
31722
- const bad = unknownTraits(traitCodes);
31851
+ function assertKnownTraits(traitCodes, entityName, localTraits) {
31852
+ const bad = unknownTraits(traitCodes, localTraits);
31723
31853
  if (bad.length === 0) return;
31854
+ const local = Object.keys(localTraits ?? {});
31724
31855
  throw new Error(
31725
- `Entity '${entityName}' declares unknown trait(s): ${bad.join(", ")}. Valid: ${TRAIT_CODES.join(", ")}. An unrecognized trait is silently ignored when columns are expanded, so its columns would quietly go missing rather than fail loudly. (See dforge://reference/traits.)`
31856
+ `Entity '${entityName}' declares unknown trait(s): ${bad.join(", ")}. Valid: ${TRAIT_CODES.join(", ")}` + (local.length > 0 ? `, plus this module's own traits.json: ${local.join(", ")}` : "") + ". An unrecognized trait is silently ignored when columns are expanded, so its columns would quietly go missing rather than fail loudly. (See dforge://reference/traits.)"
31726
31857
  );
31727
31858
  }
31728
31859
  function withTraits(entity, traitCodes) {
@@ -34317,307 +34448,1447 @@ function entityDelete(args) {
34317
34448
  // src/tools/action-add.ts
34318
34449
  var path9 = __toESM(require("path"));
34319
34450
 
34320
- // src/tools/dsl-check.ts
34321
- var BLOCKS = ["params", "canExecute", "onBeforeStart", "execute"];
34322
- var DSL_BUILTINS = /* @__PURE__ */ new Set([
34323
- "addDays",
34324
- "addMinutes",
34325
- "addSeconds",
34326
- "applyProfile",
34327
- "callApi",
34328
- "callProc",
34329
- "callService",
34330
- "currentUserId",
34331
- "delete",
34332
- "detectDocument",
34333
- "download",
34334
- "entityLink",
34335
- "error",
34336
- "exit",
34337
- "flush",
34338
- "getFileBase64",
34339
- "getFileInfo",
34340
- "getFileUrl",
34341
- "getRecord",
34342
- "getRecordOrNull",
34343
- "getSecret",
34344
- "getSetting",
34345
- "info",
34346
- "insert",
34347
- "nextNumber",
34348
- "notify",
34349
- "now",
34350
- "ocrExtract",
34351
- "preloadRef",
34352
- "query",
34353
- "select",
34354
- "sendEmail",
34355
- "tryParseJson",
34356
- "update",
34357
- "warn"
34358
- ]);
34359
- var JS_RESERVED = /* @__PURE__ */ new Set([
34451
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.27/node_modules/@dforge-core/metadata/dist/dsl/index.js
34452
+ var PUNCT3 = ["===", "!=="];
34453
+ var PUNCT2 = [
34454
+ "==",
34455
+ "!=",
34456
+ "<=",
34457
+ ">=",
34458
+ "&&",
34459
+ "||",
34460
+ "++",
34461
+ "--",
34462
+ "+=",
34463
+ "-=",
34464
+ "*=",
34465
+ "/=",
34466
+ "%=",
34467
+ "${",
34468
+ "=>"
34469
+ ];
34470
+ var CONTROL_KEYWORDS = /* @__PURE__ */ new Set([
34360
34471
  "if",
34361
- "for",
34362
34472
  "while",
34473
+ "for",
34363
34474
  "switch",
34364
34475
  "catch",
34365
- "return",
34366
- "typeof",
34367
- "function",
34368
- "do",
34369
- "else",
34370
- "in",
34371
- "of",
34372
- "new",
34373
- "delete",
34374
- "void",
34375
- "throw",
34376
- "try",
34377
- "parseInt",
34378
- "parseFloat",
34379
- "isNaN",
34380
- "isFinite",
34381
- "String",
34382
- "Number",
34383
- "Boolean",
34384
- "Array",
34385
- "Object",
34386
- "Date",
34387
- "Math",
34388
- "JSON",
34389
- "RegExp",
34390
- "Error"
34476
+ "with"
34391
34477
  ]);
34392
- var VALUE_MEMBERS = /* @__PURE__ */ new Set(["userId"]);
34393
- var FORMULA_ONLY = {
34394
- TODAY: "now()",
34395
- NOW: "now()",
34396
- CURRENT_USER_ID: "currentUserId()"
34397
- };
34398
- function blank(src) {
34399
- let out = "";
34478
+ function tokenize(text) {
34479
+ const tokens = [];
34400
34480
  let i = 0;
34401
- while (i < src.length) {
34402
- const c = src[i];
34403
- if (c === "'" || c === '"') {
34404
- const quote = c;
34405
- out += " ";
34406
- i++;
34407
- while (i < src.length && src[i] !== quote) {
34408
- if (src[i] === "\\") {
34409
- out += " ";
34481
+ let line = 0;
34482
+ let lineStart = 0;
34483
+ let pendingLineBreak = true;
34484
+ const frames = [];
34485
+ const parenIsControl = [];
34486
+ let closedControlParen = false;
34487
+ const braceIsBlock = [];
34488
+ let closedBlockBrace = false;
34489
+ const push = (kind, start, end) => {
34490
+ tokens.push({
34491
+ kind,
34492
+ text: text.slice(start, end),
34493
+ start,
34494
+ end,
34495
+ line,
34496
+ character: start - lineStart,
34497
+ startsLine: pendingLineBreak
34498
+ });
34499
+ pendingLineBreak = false;
34500
+ };
34501
+ while (i < text.length) {
34502
+ const frame = frames[frames.length - 1];
34503
+ if (frame?.kind === "template") {
34504
+ const startLine = line;
34505
+ const startLineStart = lineStart;
34506
+ while (i < text.length) {
34507
+ const ch = text[i];
34508
+ if (ch === "\\") {
34410
34509
  i += 2;
34411
34510
  continue;
34412
34511
  }
34413
- out += src[i] === "\n" ? "\n" : " ";
34512
+ if (ch === "`" || ch === "$" && text[i + 1] === "{") break;
34513
+ if (ch === "\n") {
34514
+ line++;
34515
+ lineStart = i + 1;
34516
+ }
34414
34517
  i++;
34415
34518
  }
34416
- if (i < src.length) {
34417
- out += " ";
34519
+ if (text[i] === "`") {
34520
+ i++;
34521
+ frames.pop();
34522
+ } else if (i < text.length) {
34523
+ i += 2;
34524
+ frames.push({ kind: "interp", braces: 0 });
34525
+ } else {
34526
+ frames.pop();
34527
+ }
34528
+ const savedLine = line;
34529
+ const savedLineStart = lineStart;
34530
+ line = startLine;
34531
+ lineStart = startLineStart;
34532
+ push("template", frame.chunkStart, i);
34533
+ line = savedLine;
34534
+ lineStart = savedLineStart;
34535
+ continue;
34536
+ }
34537
+ const c = text[i];
34538
+ if (c === "\uFEFF" && i === 0) {
34539
+ i++;
34540
+ lineStart = i;
34541
+ continue;
34542
+ }
34543
+ if (c === "\n") {
34544
+ i++;
34545
+ line++;
34546
+ lineStart = i;
34547
+ pendingLineBreak = true;
34548
+ continue;
34549
+ }
34550
+ if (c === " " || c === " " || c === "\r") {
34551
+ i++;
34552
+ continue;
34553
+ }
34554
+ if (c === "/" && text[i + 1] === "/") {
34555
+ const start = i;
34556
+ while (i < text.length && text[i] !== "\n") i++;
34557
+ push("comment", start, i);
34558
+ continue;
34559
+ }
34560
+ if (c === "/" && text[i + 1] === "*") {
34561
+ const start = i;
34562
+ const startLine = line;
34563
+ const startLineStart = lineStart;
34564
+ i += 2;
34565
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
34566
+ if (text[i] === "\n") {
34567
+ line++;
34568
+ lineStart = i + 1;
34569
+ }
34418
34570
  i++;
34419
34571
  }
34572
+ i = Math.min(i + 2, text.length);
34573
+ const savedLine = line;
34574
+ const savedLineStart = lineStart;
34575
+ line = startLine;
34576
+ lineStart = startLineStart;
34577
+ push("comment", start, i);
34578
+ line = savedLine;
34579
+ lineStart = savedLineStart;
34420
34580
  continue;
34421
34581
  }
34422
- if (c === "#" || c === "/" && src[i + 1] === "/") {
34423
- while (i < src.length && src[i] !== "\n") {
34424
- out += " ";
34582
+ if (c === "'" || c === '"') {
34583
+ const start = i;
34584
+ const quote = c;
34585
+ i++;
34586
+ while (i < text.length && text[i] !== quote && text[i] !== "\n") {
34587
+ if (text[i] === "\\") i++;
34425
34588
  i++;
34426
34589
  }
34590
+ if (text[i] === quote) i++;
34591
+ push("string", start, i);
34427
34592
  continue;
34428
34593
  }
34429
- out += c;
34594
+ if (c === "`") {
34595
+ frames.push({ kind: "template", chunkStart: i });
34596
+ i++;
34597
+ continue;
34598
+ }
34599
+ if (c === "/" && regexAllowedAfter(
34600
+ tokens[tokens.length - 1],
34601
+ closedControlParen,
34602
+ closedBlockBrace
34603
+ )) {
34604
+ const end = scanRegex(text, i);
34605
+ if (end > 0) {
34606
+ push("regex", i, end);
34607
+ i = end;
34608
+ continue;
34609
+ }
34610
+ }
34611
+ if (/[0-9]/.test(c)) {
34612
+ const start = i;
34613
+ while (i < text.length && /[0-9._eE]/.test(text[i])) i++;
34614
+ push("number", start, i);
34615
+ continue;
34616
+ }
34617
+ if (/[A-Za-z_$]/.test(c)) {
34618
+ const start = i;
34619
+ while (i < text.length && /[A-Za-z0-9_$]/.test(text[i])) i++;
34620
+ push("ident", start, i);
34621
+ continue;
34622
+ }
34623
+ if (c === "(") {
34624
+ const prev = tokens[tokens.length - 1];
34625
+ parenIsControl.push(
34626
+ prev?.kind === "ident" && CONTROL_KEYWORDS.has(prev.text)
34627
+ );
34628
+ } else if (c === ")") {
34629
+ closedControlParen = parenIsControl.pop() ?? false;
34630
+ }
34631
+ if (frame?.kind === "interp") {
34632
+ if (c === "}" && frame.braces === 0) {
34633
+ frames.pop();
34634
+ frames[frames.length - 1].chunkStart = i;
34635
+ i++;
34636
+ continue;
34637
+ }
34638
+ if (c === "{") frame.braces++;
34639
+ else if (c === "}") frame.braces--;
34640
+ }
34641
+ if (c === "{") {
34642
+ braceIsBlock.push(isBlockBrace(tokens[tokens.length - 1]));
34643
+ } else if (c === "}") {
34644
+ closedBlockBrace = braceIsBlock.pop() ?? false;
34645
+ }
34646
+ const three = text.slice(i, i + 3);
34647
+ if (PUNCT3.includes(three)) {
34648
+ push("punct", i, i + 3);
34649
+ i += 3;
34650
+ continue;
34651
+ }
34652
+ const two = text.slice(i, i + 2);
34653
+ if (PUNCT2.includes(two)) {
34654
+ push("punct", i, i + 2);
34655
+ i += 2;
34656
+ continue;
34657
+ }
34658
+ push("punct", i, i + 1);
34430
34659
  i++;
34431
34660
  }
34432
- return out;
34433
- }
34434
- var lineOf = (src, index) => src.slice(0, index).split("\n").length;
34435
- function readStringLiteral(src, start) {
34436
- const quote = src[start];
34437
- if (quote !== "'" && quote !== '"') return null;
34438
- let value = "";
34661
+ return tokens;
34662
+ }
34663
+ function regexAllowedAfter(prev, closedControlParen, closedBlockBrace) {
34664
+ if (!prev) return true;
34665
+ if (prev.kind === "number" || prev.kind === "string") return false;
34666
+ if (prev.kind === "template") return prev.text.endsWith("${");
34667
+ if (prev.kind === "ident") return REGEX_PRECEDING_KEYWORDS.has(prev.text);
34668
+ if (prev.kind === "regex") return false;
34669
+ if (prev.kind === "comment") return true;
34670
+ if (prev.text === ")") return closedControlParen;
34671
+ if (prev.text === "}") return closedBlockBrace;
34672
+ return !REGEX_BLOCKING_PUNCT.has(prev.text);
34673
+ }
34674
+ function isBlockBrace(prev) {
34675
+ if (!prev) return true;
34676
+ if (prev.kind === "ident") return BLOCK_PRECEDING_KEYWORDS.has(prev.text);
34677
+ if (prev.kind !== "punct") return false;
34678
+ return prev.text === ")" || prev.text === "}" || prev.text === "{" || prev.text === ";";
34679
+ }
34680
+ var BLOCK_PRECEDING_KEYWORDS = /* @__PURE__ */ new Set(["else", "do", "try", "finally"]);
34681
+ var REGEX_PRECEDING_KEYWORDS = /* @__PURE__ */ new Set([
34682
+ "return",
34683
+ "typeof",
34684
+ "instanceof",
34685
+ "in",
34686
+ "of",
34687
+ "new",
34688
+ "delete",
34689
+ "void",
34690
+ "case",
34691
+ "do",
34692
+ "else",
34693
+ "throw",
34694
+ "yield",
34695
+ "await"
34696
+ ]);
34697
+ var REGEX_BLOCKING_PUNCT = /* @__PURE__ */ new Set(["]", "++", "--"]);
34698
+ function scanRegex(text, start) {
34439
34699
  let i = start + 1;
34440
- while (i < src.length) {
34441
- const c = src[i];
34700
+ let inClass = false;
34701
+ if (text[i] === "/" || text[i] === "*") return -1;
34702
+ for (; i < text.length; i++) {
34703
+ const c = text[i];
34442
34704
  if (c === "\\") {
34443
- if (i + 1 < src.length) value += src[i + 1];
34705
+ i++;
34706
+ continue;
34707
+ }
34708
+ if (c === "\n") return -1;
34709
+ if (c === "[") inClass = true;
34710
+ else if (c === "]") inClass = false;
34711
+ else if (c === "/" && !inClass) {
34712
+ i++;
34713
+ while (i < text.length && /[a-z]/.test(text[i])) i++;
34714
+ return i;
34715
+ }
34716
+ }
34717
+ return -1;
34718
+ }
34719
+ function stringValue(token) {
34720
+ if (token.kind !== "string") return token.text;
34721
+ const body = token.text.slice(1, token.text.endsWith(token.text[0]) && token.text.length > 1 ? -1 : void 0);
34722
+ return unescape2(body);
34723
+ }
34724
+ function templateChunkValue(token) {
34725
+ if (token.kind !== "template") return token.text;
34726
+ const body = token.text.slice(1);
34727
+ if (body.endsWith("${")) return unescape2(body.slice(0, -2));
34728
+ if (body.endsWith("`")) return unescape2(body.slice(0, -1));
34729
+ return unescape2(body);
34730
+ }
34731
+ function unescape2(body) {
34732
+ return body.replace(/\\(.)/g, (_, ch) => {
34733
+ switch (ch) {
34734
+ case "n":
34735
+ return "\n";
34736
+ case "t":
34737
+ return " ";
34738
+ case "r":
34739
+ return "\r";
34740
+ default:
34741
+ return ch;
34742
+ }
34743
+ });
34744
+ }
34745
+ var BLOCK_KINDS = [
34746
+ "params",
34747
+ "canExecute",
34748
+ "schema",
34749
+ "onBeforeStart",
34750
+ "execute"
34751
+ ];
34752
+ var BLOCK_BY_LOWER = new Map(BLOCK_KINDS.map((k) => [k.toLowerCase(), k]));
34753
+ var GLOBALS = /* @__PURE__ */ new Set(["params", "old", "records"]);
34754
+ function parseDsl(text) {
34755
+ const all = tokenize(text);
34756
+ const tokens = all.filter((t) => t.kind !== "comment");
34757
+ const blocks = findBlocks(text, tokens);
34758
+ const blockAt = (offset) => blocks.find((b) => offset >= b.body.start && offset < b.body.end)?.kind ?? null;
34759
+ const params = parseParams(tokens, blocks);
34760
+ const fieldRefs = [];
34761
+ const globalRefs = [];
34762
+ const calls = [];
34763
+ const recordsRefs = [];
34764
+ const locals = collectLocals(tokens, params);
34765
+ const recordsLoopVars = collectRecordsLoopVars(tokens);
34766
+ for (let i = 0; i < tokens.length; i++) {
34767
+ const t = tokens[i];
34768
+ if (t.kind === "punct" && t.text === "[" && isFieldAccessStart(text, t.start)) {
34769
+ const name = tokens[i + 1];
34770
+ const close = tokens[i + 2];
34771
+ if (name?.kind === "ident" && close?.text === "]") {
34772
+ const ref = {
34773
+ name: name.text,
34774
+ span: { start: name.start, end: name.end },
34775
+ outerSpan: { start: t.start, end: close.end },
34776
+ navigation: [],
34777
+ block: blockAt(t.start),
34778
+ isCurrentRecord: true,
34779
+ startIndex: i,
34780
+ endIndex: i + 2
34781
+ };
34782
+ ref.endIndex = collectNavigation(tokens, i + 2, ref);
34783
+ i = ref.endIndex;
34784
+ fieldRefs.push(ref);
34785
+ continue;
34786
+ }
34787
+ }
34788
+ if (t.kind === "ident" && t.text === "records" && tokens[i - 1]?.text !== ".") {
34789
+ recordsRefs.push({ start: t.start, end: t.end });
34790
+ const field = indexedRecordField(tokens, i);
34791
+ if (field) {
34792
+ fieldRefs.push({
34793
+ name: field.name.text,
34794
+ span: { start: field.name.start, end: field.name.end },
34795
+ outerSpan: { start: field.open.start, end: field.close.end },
34796
+ navigation: [],
34797
+ block: blockAt(t.start),
34798
+ isCurrentRecord: false,
34799
+ startIndex: field.openIndex,
34800
+ endIndex: field.openIndex + 2
34801
+ });
34802
+ i = field.openIndex + 2;
34803
+ continue;
34804
+ }
34805
+ }
34806
+ if (t.kind === "ident" && recordsLoopVars.has(t.text)) {
34807
+ const open = tokens[i + 1];
34808
+ const name = tokens[i + 2];
34809
+ const close = tokens[i + 3];
34810
+ if (open?.text === "[" && name?.kind === "ident" && close?.text === "]") {
34811
+ fieldRefs.push({
34812
+ name: name.text,
34813
+ span: { start: name.start, end: name.end },
34814
+ outerSpan: { start: open.start, end: close.end },
34815
+ navigation: [],
34816
+ block: blockAt(t.start),
34817
+ isCurrentRecord: false,
34818
+ startIndex: i + 1,
34819
+ endIndex: i + 3
34820
+ });
34821
+ i += 3;
34822
+ continue;
34823
+ }
34824
+ }
34825
+ if (t.kind === "ident" && GLOBALS.has(t.text)) {
34826
+ const open = tokens[i + 1];
34827
+ const prop = tokens[i + 2];
34828
+ const close = tokens[i + 3];
34829
+ if (open?.text === "[" && prop?.kind === "ident" && close?.text === "]") {
34830
+ globalRefs.push({
34831
+ global: t.text,
34832
+ property: prop.text,
34833
+ span: { start: prop.start, end: prop.end },
34834
+ block: blockAt(t.start)
34835
+ });
34836
+ i += 3;
34837
+ continue;
34838
+ }
34839
+ }
34840
+ if (t.kind === "ident" && tokens[i + 1]?.text === "(") {
34841
+ const prev = tokens[i - 1];
34842
+ if (prev?.text === ".") continue;
34843
+ const call = {
34844
+ name: t.text,
34845
+ nameSpan: { start: t.start, end: t.end },
34846
+ block: blockAt(t.start)
34847
+ };
34848
+ const firstArg = tokens[i + 2];
34849
+ if (firstArg?.kind === "string") {
34850
+ call.firstStringArg = {
34851
+ value: stringValue(firstArg),
34852
+ span: { start: firstArg.start, end: firstArg.end },
34853
+ interpolated: false,
34854
+ endIndex: i + 2
34855
+ };
34856
+ } else if (firstArg?.kind === "template" && firstArg.text.startsWith("`")) {
34857
+ call.firstStringArg = templateArg(tokens, i + 2);
34858
+ }
34859
+ calls.push(call);
34860
+ }
34861
+ }
34862
+ return {
34863
+ blocks,
34864
+ params,
34865
+ fieldRefs,
34866
+ globalRefs,
34867
+ calls,
34868
+ tokens,
34869
+ recordsRefs,
34870
+ locals,
34871
+ recordsLoopVars
34872
+ };
34873
+ }
34874
+ function isFieldAccessStart(text, start) {
34875
+ return start === 0 || !/\w/.test(text[start - 1]);
34876
+ }
34877
+ function templateArg(tokens, start) {
34878
+ const parts = [];
34879
+ let depth = 0;
34880
+ let interpolated = false;
34881
+ let last = tokens[start];
34882
+ let endIndex = start;
34883
+ for (let j = start; j < tokens.length; j++) {
34884
+ const t = tokens[j];
34885
+ if (t.kind !== "template") continue;
34886
+ const opens = t.text.startsWith("`");
34887
+ if (opens) depth++;
34888
+ if (depth === 1) {
34889
+ if (!opens) parts.push(" ");
34890
+ parts.push(templateChunkValue(t));
34891
+ last = t;
34892
+ endIndex = j;
34893
+ }
34894
+ if (t.text.endsWith("${")) {
34895
+ if (depth === 1) interpolated = true;
34896
+ continue;
34897
+ }
34898
+ depth--;
34899
+ if (depth === 0) break;
34900
+ }
34901
+ return {
34902
+ value: parts.join(""),
34903
+ span: { start: tokens[start].start, end: last.end },
34904
+ interpolated,
34905
+ endIndex
34906
+ };
34907
+ }
34908
+ function indexedRecordField(tokens, i) {
34909
+ const index = tokens[i + 2];
34910
+ if (tokens[i + 1]?.text !== "[") return null;
34911
+ if (index?.kind !== "number" || !/^\d+$/.test(index.text)) return null;
34912
+ if (tokens[i + 3]?.text !== "]") return null;
34913
+ const open = tokens[i + 4];
34914
+ const name = tokens[i + 5];
34915
+ const close = tokens[i + 6];
34916
+ if (open?.text !== "[" || name?.kind !== "ident" || close?.text !== "]")
34917
+ return null;
34918
+ return { name, open, close, openIndex: i + 4 };
34919
+ }
34920
+ function collectNavigation(tokens, closeIdx, ref) {
34921
+ let i = closeIdx;
34922
+ for (; ; ) {
34923
+ const dot = tokens[i + 1];
34924
+ if (dot?.text !== ".") return i;
34925
+ const next = tokens[i + 2];
34926
+ if (next?.kind === "ident") {
34927
+ if (tokens[i + 3]?.text === "(") return i;
34928
+ ref.navigation.push({
34929
+ name: next.text,
34930
+ span: { start: next.start, end: next.end },
34931
+ bracketed: false
34932
+ });
34444
34933
  i += 2;
34445
34934
  continue;
34446
34935
  }
34447
- if (c === quote) return { value, end: i + 1 };
34448
- value += c;
34449
- i++;
34936
+ if (next?.text === "[") {
34937
+ const name = tokens[i + 3];
34938
+ const close = tokens[i + 4];
34939
+ if (name?.kind === "ident" && close?.text === "]") {
34940
+ ref.navigation.push({
34941
+ name: name.text,
34942
+ span: { start: name.start, end: name.end },
34943
+ bracketed: true
34944
+ });
34945
+ i += 4;
34946
+ continue;
34947
+ }
34948
+ }
34949
+ return i;
34450
34950
  }
34451
- return null;
34452
34951
  }
34453
- function sqlLiterals(src) {
34454
- const out = [];
34455
- const callRe = /\b(query|callProc)\s*\(\s*/g;
34456
- for (const m of src.matchAll(callRe)) {
34457
- const argStart = (m.index ?? 0) + m[0].length;
34458
- const lit = readStringLiteral(src, argStart);
34459
- if (!lit) continue;
34460
- out.push({ sql: lit.value, index: m.index ?? 0, end: lit.end });
34952
+ function collectRecordsLoopVars(tokens) {
34953
+ const vars = /* @__PURE__ */ new Set();
34954
+ for (let i = 0; i < tokens.length - 4; i++) {
34955
+ if (tokens[i].text !== "for" || tokens[i].kind !== "ident") continue;
34956
+ const name = tokens[i + 1];
34957
+ if (name.kind !== "ident") continue;
34958
+ if (tokens[i + 2].text !== "in" || tokens[i + 3].text !== "records") continue;
34959
+ if (tokens[i + 4].text !== "{") continue;
34960
+ vars.add(name.text);
34461
34961
  }
34462
- return out;
34962
+ return vars;
34463
34963
  }
34464
- function parseBlocks(src) {
34465
- const lines = src.split("\n");
34466
- const found = [];
34467
- let offset = 0;
34468
- for (let i = 0; i < lines.length; i++) {
34469
- const m = lines[i].match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*$/);
34470
- if (m) found.push({ block: m[1], start: offset + m[0].length, end: src.length, line: i + 1 });
34471
- offset += lines[i].length + 1;
34964
+ var DECLARATORS = /* @__PURE__ */ new Set(["var", "let", "const"]);
34965
+ function collectLocals(tokens, params) {
34966
+ const locals = new Set(params.map((p) => p.name));
34967
+ collectFunctionParams(tokens, locals);
34968
+ for (let i = 0; i < tokens.length; i++) {
34969
+ const t = tokens[i];
34970
+ if (t.kind !== "ident") continue;
34971
+ if (DECLARATORS.has(t.text)) {
34972
+ collectDeclarations(tokens, i + 1, locals);
34973
+ continue;
34974
+ }
34975
+ if (t.text === "for") {
34976
+ let j = i + 1;
34977
+ if (tokens[j]?.text === "(") j++;
34978
+ if (tokens[j]?.kind === "ident" && DECLARATORS.has(tokens[j].text)) j++;
34979
+ const name = tokens[j];
34980
+ if (name?.kind === "ident" && !DECLARATORS.has(name.text)) locals.add(name.text);
34981
+ }
34472
34982
  }
34473
- for (let i = 0; i < found.length - 1; i++) {
34474
- found[i].end = src.lastIndexOf("\n", found[i + 1].start - found[i + 1].block.length - 2) + 1;
34983
+ return locals;
34984
+ }
34985
+ function collectFunctionParams(tokens, locals) {
34986
+ for (let i = 0; i < tokens.length; i++) {
34987
+ const t = tokens[i];
34988
+ if (t.kind === "ident" && t.text === "function") {
34989
+ let j = i + 1;
34990
+ if (tokens[j]?.kind === "ident") j++;
34991
+ if (tokens[j]?.text === "(") collectParenIdents(tokens, j, locals);
34992
+ continue;
34993
+ }
34994
+ if (t.text === "=>") {
34995
+ const prev = tokens[i - 1];
34996
+ if (prev?.kind === "ident") locals.add(prev.text);
34997
+ else if (prev?.text === ")") {
34998
+ const open = matchingOpenParen(tokens, i - 1);
34999
+ if (open >= 0) collectParenIdents(tokens, open, locals);
35000
+ }
35001
+ continue;
35002
+ }
35003
+ if (t.kind === "ident" && !CONTROL_KEYWORDS.has(t.text) && tokens[i + 1]?.text === "(") {
35004
+ const close = matchingCloseParen(tokens, i + 1);
35005
+ if (close >= 0 && tokens[close + 1]?.text === "{")
35006
+ collectParenIdents(tokens, i + 1, locals);
35007
+ }
35008
+ }
35009
+ }
35010
+ function collectParenIdents(tokens, openIndex, locals) {
35011
+ let depth = 0;
35012
+ for (let i = openIndex; i < tokens.length; i++) {
35013
+ const t = tokens[i];
35014
+ if (t.text === "(") depth++;
35015
+ else if (t.text === ")") {
35016
+ depth--;
35017
+ if (depth === 0) return;
35018
+ } else if (t.kind === "ident") locals.add(t.text);
35019
+ }
35020
+ }
35021
+ function matchingCloseParen(tokens, openIndex) {
35022
+ let depth = 0;
35023
+ for (let i = openIndex; i < tokens.length; i++) {
35024
+ if (tokens[i].text === "(") depth++;
35025
+ else if (tokens[i].text === ")" && --depth === 0) return i;
35026
+ }
35027
+ return -1;
35028
+ }
35029
+ function matchingOpenParen(tokens, closeIndex) {
35030
+ let depth = 0;
35031
+ for (let i = closeIndex; i >= 0; i--) {
35032
+ if (tokens[i].text === ")") depth++;
35033
+ else if (tokens[i].text === "(" && --depth === 0) return i;
35034
+ }
35035
+ return -1;
35036
+ }
35037
+ function collectDeclarations(tokens, start, locals) {
35038
+ let j = start;
35039
+ for (; ; ) {
35040
+ const head = tokens[j];
35041
+ if (!head) return;
35042
+ if (head.text === "{" || head.text === "[") {
35043
+ j = collectPattern(tokens, j, locals);
35044
+ } else if (head.kind === "ident" && !DECLARATORS.has(head.text)) {
35045
+ locals.add(head.text);
35046
+ j++;
35047
+ } else return;
35048
+ let depth = 0;
35049
+ for (; j < tokens.length; j++) {
35050
+ const n = tokens[j];
35051
+ if (n.text === "(" || n.text === "[" || n.text === "{") depth++;
35052
+ else if (n.text === ")" || n.text === "]" || n.text === "}") depth--;
35053
+ if (depth < 0) return;
35054
+ if (depth > 0) continue;
35055
+ if (n.text === ";") return;
35056
+ if (n.text === ",") break;
35057
+ if (n.startsLine) return;
35058
+ }
35059
+ if (j >= tokens.length) return;
35060
+ j++;
35061
+ }
35062
+ }
35063
+ function collectPattern(tokens, start, locals) {
35064
+ let depth = 0;
35065
+ let i = start;
35066
+ for (; i < tokens.length; i++) {
35067
+ const t = tokens[i];
35068
+ if (t.text === "{" || t.text === "[") depth++;
35069
+ else if (t.text === "}" || t.text === "]") {
35070
+ depth--;
35071
+ if (depth === 0) return i + 1;
35072
+ } else if (t.kind === "ident") locals.add(t.text);
35073
+ }
35074
+ return i;
35075
+ }
35076
+ function blockLabelAt(tokens, i) {
35077
+ const t = tokens[i];
35078
+ if (!t || t.kind !== "ident" || !t.startsLine) return null;
35079
+ const colon = tokens[i + 1];
35080
+ if (colon?.text !== ":" || colon.start !== t.end) return null;
35081
+ const kind = BLOCK_BY_LOWER.get(t.text.toLowerCase());
35082
+ return kind ? { kind, span: { start: t.start, end: colon.end } } : null;
35083
+ }
35084
+ function findBlocks(text, tokens) {
35085
+ const labels = [];
35086
+ for (let i = 0; i < tokens.length; i++) {
35087
+ const t = tokens[i];
35088
+ if (t.character !== 0) continue;
35089
+ const label = blockLabelAt(tokens, i);
35090
+ if (!label) continue;
35091
+ labels.push(label);
35092
+ if (label.kind === "execute") break;
35093
+ }
35094
+ return labels.map((l, idx) => ({
35095
+ kind: l.kind,
35096
+ label: l.span,
35097
+ body: {
35098
+ start: l.span.end,
35099
+ end: labels[idx + 1]?.span.start ?? text.length
35100
+ }
35101
+ }));
35102
+ }
35103
+ function parseParams(tokens, blocks) {
35104
+ const block = blocks.find((b) => b.kind === "params");
35105
+ if (!block) return [];
35106
+ const inBlock = tokens.filter(
35107
+ (t) => t.start >= block.body.start && t.start < block.body.end
35108
+ );
35109
+ const params = [];
35110
+ for (let i = 0; i < inBlock.length; i++) {
35111
+ const name = inBlock[i];
35112
+ if (!name.startsLine && i !== 0 || name.kind !== "ident") continue;
35113
+ if (inBlock[i + 1]?.text !== ":") continue;
35114
+ let j = i + 2;
35115
+ let isRef = false;
35116
+ if (inBlock[j]?.kind === "ident" && inBlock[j].text === "ref") {
35117
+ isRef = true;
35118
+ j++;
35119
+ }
35120
+ const type = inBlock[j];
35121
+ if (!type || type.kind !== "ident") continue;
35122
+ if (type.line !== name.line) continue;
35123
+ let typeEnd = type.end;
35124
+ let typeText = type.text;
35125
+ if (isRef) {
35126
+ while (inBlock[j + 1] && (inBlock[j + 1].text === "-" || inBlock[j + 1].text === ".") && inBlock[j + 2]?.kind === "ident" && inBlock[j + 1].start === typeEnd && inBlock[j + 2].start === inBlock[j + 1].end) {
35127
+ typeText += inBlock[j + 1].text + inBlock[j + 2].text;
35128
+ typeEnd = inBlock[j + 2].end;
35129
+ j += 2;
35130
+ }
35131
+ }
35132
+ let required2 = false;
35133
+ let label;
35134
+ for (let k = j + 1; k < inBlock.length && !inBlock[k].startsLine; k++) {
35135
+ const t = inBlock[k];
35136
+ if (t.kind === "ident" && t.text === "required") required2 = true;
35137
+ if (t.kind === "string" && label === void 0) label = stringValue(t);
35138
+ }
35139
+ params.push({
35140
+ name: name.text,
35141
+ nameSpan: { start: name.start, end: name.end },
35142
+ type: typeText,
35143
+ typeSpan: { start: type.start, end: typeEnd },
35144
+ isRef,
35145
+ required: required2,
35146
+ label
35147
+ });
34475
35148
  }
34476
- return found;
35149
+ return params;
34477
35150
  }
34478
- function checkDsl(src, opts = {}) {
34479
- const issues = [];
34480
- const add = (level, rule, message, line) => issues.push({ level, rule, message, ...line ? { line } : {} });
34481
- const code2 = blank(src);
34482
- const blocks = parseBlocks(src);
34483
- const names = blocks.map((b) => b.block);
34484
- if (src.trim() === "") {
34485
- add("error", "dsl-empty", "DSL body is empty \u2014 at minimum it needs an `execute:` block.");
34486
- return issues;
34487
- }
34488
- if (!names.includes("execute")) {
35151
+ var BUILTINS = [
35152
+ // Data operations
35153
+ {
35154
+ name: "insert",
35155
+ signature: "insert(entity, fields)",
35156
+ returns: "object",
35157
+ entityArg: 0,
35158
+ doc: "Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences)."
35159
+ },
35160
+ {
35161
+ name: "select",
35162
+ signature: "select(entity, opts?)",
35163
+ returns: "object[]",
35164
+ entityArg: 0,
35165
+ doc: "Structured multi-row read. `opts` = `{columns, filter, orderBy, limit, offset}`. Prefer over `query()` for row-shaped reads."
35166
+ },
35167
+ {
35168
+ name: "update",
35169
+ signature: "update(entity, key, fields)",
35170
+ returns: "number",
35171
+ entityArg: 0,
35172
+ 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 \u2014 use that for every counter, balance and quantity."
35173
+ },
35174
+ {
35175
+ name: "delete",
35176
+ signature: "delete(entity, key)",
35177
+ returns: "number",
35178
+ entityArg: 0,
35179
+ doc: "Delete the row(s) matched by `key`; returns rows affected."
35180
+ },
35181
+ {
35182
+ name: "query",
35183
+ signature: "query(sql, args?)",
35184
+ returns: "object[]",
35185
+ doc: "Execute parameterized SQL with `@argName` placeholders and schema-qualified table names. Escape hatch \u2014 prefer `select()` / `update()` / `delete()` when the structured shape fits."
35186
+ },
35187
+ {
35188
+ name: "getRecord",
35189
+ signature: "getRecord(entity, key)",
35190
+ returns: "object",
35191
+ entityArg: 0,
35192
+ doc: "Fetch a single record by key. **Throws** a localized not-found error when absent."
35193
+ },
35194
+ {
35195
+ name: "getRecordOrNull",
35196
+ signature: "getRecordOrNull(entity, key)",
35197
+ returns: "object | null",
35198
+ entityArg: 0,
35199
+ doc: "Like `getRecord`, but returns `null` instead of throwing \u2014 for an expected absence, guarded with `if (rec == null)`."
35200
+ },
35201
+ {
35202
+ name: "preloadRef",
35203
+ signature: "preloadRef(fkColumn)",
35204
+ returns: "object",
35205
+ doc: "Load the referenced record for a FK column. Read its fields with `ref.get('field')`."
35206
+ },
35207
+ // Messaging
35208
+ {
35209
+ name: "error",
35210
+ signature: "error(message)",
35211
+ returns: "never",
35212
+ doc: "Abort the action and show an error. Rolls back all changes."
35213
+ },
35214
+ { name: "warn", signature: "warn(message)", returns: "void", doc: "Show a warning and continue." },
35215
+ {
35216
+ name: "info",
35217
+ signature: "info(message)",
35218
+ returns: "void",
35219
+ doc: "Show an informational message \u2014 a receipt for work the action did, not a way to publish a computed value."
35220
+ },
35221
+ {
35222
+ name: "exit",
35223
+ signature: "exit(message?, level?)",
35224
+ returns: "never",
35225
+ doc: "Stop the script early **without** error, keeping changes made so far. Level `'info'` (default) or `'warn'`. This is the DSL's early return \u2014 the block compiles to a bare script, so a `return` outside a function is a syntax error wherever it sits."
35226
+ },
35227
+ {
35228
+ name: "notify",
35229
+ signature: "notify(userId, message)",
35230
+ returns: "void",
35231
+ doc: "Send an in-app notification to a specific user."
35232
+ },
35233
+ {
35234
+ name: "sendEmail",
35235
+ signature: "sendEmail(to, subjectOrTemplate, dataOrBody?)",
35236
+ returns: "void",
35237
+ 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>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. There is no fourth argument."
35238
+ },
35239
+ // External calls
35240
+ {
35241
+ name: "callApi",
35242
+ signature: "callApi(url, method, headers?, body?)",
35243
+ returns: "string",
35244
+ doc: "HTTP request to an external API; returns the body as a string. Max 10 calls per script, 120s timeout, 5MB response."
35245
+ },
35246
+ {
35247
+ name: "callService",
35248
+ signature: "callService(name, args)",
35249
+ returns: "any",
35250
+ doc: "Invoke a registered host service."
35251
+ },
35252
+ {
35253
+ name: "callProc",
35254
+ signature: "callProc(procName, args)",
35255
+ returns: "any",
35256
+ doc: "Call a stored procedure with named parameters."
35257
+ },
35258
+ // Files, settings, extraction
35259
+ {
35260
+ name: "getFileBase64",
35261
+ signature: "getFileBase64(fileField)",
35262
+ returns: "string",
35263
+ doc: "Read a file from storage as base64. Use with `callApi()` to send files to external APIs. Max 10 MB."
35264
+ },
35265
+ {
35266
+ name: "getFileUrl",
35267
+ signature: "getFileUrl(fileField)",
35268
+ returns: "string",
35269
+ doc: "Temporary signed download URL (relative path) for a file field \u2014 for emails and notifications, not for `callApi()`."
35270
+ },
35271
+ { name: "getFileInfo", signature: "getFileInfo(fileField)", returns: "object | null", doc: "File metadata for a file field." },
35272
+ {
35273
+ name: "download",
35274
+ signature: "download(url, fileName?)",
35275
+ returns: "void",
35276
+ doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing \u2014 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."
35277
+ },
35278
+ {
35279
+ name: "getSetting",
35280
+ signature: "getSetting(settingCd)",
35281
+ returns: "any",
35282
+ doc: "Read a module setting, resolved through the folder chain (current folder \u2192 parents \u2192 module default)."
35283
+ },
35284
+ {
35285
+ name: "getSecret",
35286
+ signature: "getSecret(secretCd)",
35287
+ returns: "string",
35288
+ doc: "Retrieve a decrypted secret (API key, token). Managed in the admin UI."
35289
+ },
35290
+ {
35291
+ name: "ocrExtract",
35292
+ signature: "ocrExtract(fileField, endpointBaseUrl, schema?, opts?)",
35293
+ returns: "string (v1) | object (v2)",
35294
+ doc: "Run OCR extraction against a document. **The return type follows the mode, and only `opts.mode` selects it** \u2014 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 \u2014 `{ 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`."
35295
+ },
35296
+ {
35297
+ name: "detectDocument",
35298
+ signature: "detectDocument(rawText)",
35299
+ returns: "object | null",
35300
+ doc: "Score text against reachable extraction profiles; returns the best match `{ profile, docType, score }` or `null`."
35301
+ },
35302
+ {
35303
+ name: "applyProfile",
35304
+ signature: "applyProfile(json, profile)",
35305
+ returns: "object",
35306
+ doc: "Map already-extracted JSON to target fields and return the mapped object \u2014 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? } }` \u2014 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."
35307
+ },
35308
+ // Utilities
35309
+ { name: "now", signature: "now()", returns: "datetime", doc: "Current date/time. Lowercase \u2014 this is the execute-block date function." },
35310
+ { name: "addDays", signature: "addDays(date, n)", returns: "date", doc: "Add `n` days to a date." },
35311
+ { name: "addMinutes", signature: "addMinutes(date, n)", returns: "datetime", doc: "Add `n` minutes." },
35312
+ { name: "addSeconds", signature: "addSeconds(date, n)", returns: "datetime", doc: "Add `n` seconds." },
35313
+ { name: "currentUserId", signature: "currentUserId()", returns: "long", doc: "The acting user's tenant user ID." },
35314
+ { name: "flush", signature: "flush()", returns: "void", doc: "Flush pending writes so a following read sees them." },
35315
+ { name: "tryParseJson", signature: "tryParseJson(value)", returns: "any | null", doc: "Parse JSON, returning `null` on failure instead of throwing." },
35316
+ {
35317
+ name: "nextNumber",
35318
+ signature: "nextNumber(entity)",
35319
+ returns: "string",
35320
+ entityArg: 0,
35321
+ doc: "Generate the next value from a number sequence. Usually unnecessary \u2014 the platform auto-fills on `insert()`."
35322
+ },
35323
+ {
35324
+ name: "entityLink",
35325
+ signature: "entityLink(entityCd, record, description?)",
35326
+ returns: "link",
35327
+ entityArg: 0,
35328
+ doc: "Build a clickable link to a record, for an `entitylink` column. Always pass a **qualified** code \u2014 `entity_cd` is only unique per module."
35329
+ }
35330
+ ];
35331
+ var BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.name, b]));
35332
+ function checkDsl(text, ctx = {}) {
35333
+ const parsed = parseDsl(text);
35334
+ const out = [];
35335
+ const positionAt = lineIndexer(text);
35336
+ const add = (span, message, severity, rule) => {
35337
+ const { line, column } = positionAt(span.start);
35338
+ out.push({
35339
+ rule,
35340
+ severity,
35341
+ message,
35342
+ start: span.start,
35343
+ end: span.end,
35344
+ line,
35345
+ column
35346
+ });
35347
+ };
35348
+ const action = ctx.action;
35349
+ const entity = ctx.currentEntity ?? null;
35350
+ const isBatch = (action?.executionMode ?? "single") === "batch";
35351
+ const hasColumn = columnMatcher(entity?.columns);
35352
+ if (text.trim() === "") {
34489
35353
  add(
35354
+ { start: 0, end: text.length },
35355
+ "A DSL body needs at least an execute: block \u2014 an empty file installs an action that does nothing.",
34490
35356
  "error",
34491
- "missing-execute",
34492
- "no `execute:` block \u2014 it is the only required block in a .dsl file."
35357
+ "dsl/empty-script"
34493
35358
  );
35359
+ return out;
34494
35360
  }
34495
- for (const b of blocks) {
34496
- if (!BLOCKS.includes(b.block)) {
35361
+ const firstToken = parsed.tokens[0];
35362
+ const firstLabel = parsed.blocks[0]?.label ?? (firstToken ? { start: firstToken.start, end: firstToken.end } : { start: 0, end: text.length });
35363
+ if (!parsed.blocks.some((b) => b.kind === "execute")) {
35364
+ add(
35365
+ firstLabel,
35366
+ "No execute: block \u2014 it is the only required block, and the compiler runs nothing without it.",
35367
+ "error",
35368
+ "dsl/missing-execute"
35369
+ );
35370
+ }
35371
+ const executeBlock = parsed.blocks.find((b) => b.kind === "execute");
35372
+ if (executeBlock) {
35373
+ for (let i = 0; i < parsed.tokens.length; i++) {
35374
+ const token = parsed.tokens[i];
35375
+ if (token.start <= executeBlock.label.start) continue;
35376
+ if (token.character !== 0) continue;
35377
+ const label = blockLabelAt(parsed.tokens, i);
35378
+ if (!label) continue;
34497
35379
  add(
34498
- "warning",
34499
- "unknown-block",
34500
- `'${b.block}:' is not a DSL block marker \u2014 expected one of ${BLOCKS.join(", ")}. (A stray label at column 0 is parsed as a block.)`,
34501
- b.line
35380
+ label.span,
35381
+ 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:.`,
35382
+ "error",
35383
+ "dsl/block-after-execute"
34502
35384
  );
34503
35385
  }
34504
35386
  }
34505
- const known = names.filter((n) => BLOCKS.includes(n));
34506
- const order = known.map((n) => BLOCKS.indexOf(n));
34507
- for (let i = 1; i < order.length; i++) {
34508
- if (order[i] < order[i - 1]) {
35387
+ const seenBlocks = /* @__PURE__ */ new Set();
35388
+ for (let i = 0; i < parsed.blocks.length; i++) {
35389
+ const block = parsed.blocks[i];
35390
+ if (seenBlocks.has(block.kind)) {
34509
35391
  add(
35392
+ block.label,
35393
+ `"${block.kind}:" is declared more than once \u2014 the compiler keeps one and silently drops the other.`,
34510
35394
  "error",
34511
- "block-order",
34512
- `blocks must appear in the order ${BLOCKS.join(" \u2192 ")}; found '${known[i - 1]}:' before '${known[i]}:'.`
35395
+ "dsl/duplicate-block"
35396
+ );
35397
+ continue;
35398
+ }
35399
+ seenBlocks.add(block.kind);
35400
+ const prev = parsed.blocks[i - 1];
35401
+ if (prev && prev.kind !== "execute" && !BLOCK_STOPS[prev.kind].has(block.kind)) {
35402
+ add(
35403
+ block.label,
35404
+ `"${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.`,
35405
+ "error",
35406
+ "dsl/block-order"
35407
+ );
35408
+ }
35409
+ }
35410
+ for (const ref of parsed.fieldRefs) {
35411
+ if (ref.block === "schema" || ref.block === "params") continue;
35412
+ const isDslBlock = ref.block === "execute" || ref.block === "onBeforeStart";
35413
+ const secondRef = ref.navigation.findIndex((h, idx) => idx > 0 && h.bracketed);
35414
+ if (isDslBlock && secondRef > 0) {
35415
+ const first = ref.navigation[secondRef];
35416
+ const last = ref.navigation[ref.navigation.length - 1];
35417
+ add(
35418
+ { start: first.span.start, end: last.span.end },
35419
+ `Ref navigation is single-hop in the DSL. Use getRecord() or select() to reach past "${ref.navigation[0].name}".`,
35420
+ "error",
35421
+ "dsl/multi-hop-nav"
34513
35422
  );
34514
- break;
34515
35423
  }
35424
+ if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
35425
+ continue;
35426
+ if (!entity) continue;
35427
+ if (entity.columns.size === 0) continue;
35428
+ if (hasColumn(ref.name)) continue;
35429
+ if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
35430
+ add(
35431
+ ref.span,
35432
+ `"${ref.name}" is not a column on ${entity.qualified}. [field] names are checked at pack time \u2014 this is a compile error, not an empty read.`,
35433
+ "error",
35434
+ "dsl/unknown-column"
35435
+ );
34516
35436
  }
34517
- for (const b of BLOCKS) {
34518
- if (known.filter((n) => n === b).length > 1) {
34519
- add("error", "duplicate-block", `'${b}:' is declared more than once.`);
35437
+ if (entity && entity.columns.size > 0) {
35438
+ for (const ref of parsed.globalRefs) {
35439
+ if (ref.global !== "old") continue;
35440
+ if (ref.block !== "execute" && ref.block !== "onBeforeStart") continue;
35441
+ if (hasColumn(ref.property)) continue;
35442
+ add(
35443
+ ref.span,
35444
+ `"${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.`,
35445
+ "error",
35446
+ "dsl/unknown-column"
35447
+ );
34520
35448
  }
34521
35449
  }
34522
- const execBlock = blocks.find((b) => b.block === "execute");
34523
- const execCode = execBlock ? code2.slice(execBlock.start, execBlock.end) : "";
34524
- const execOffset = execBlock ? execBlock.start : 0;
34525
- if (execBlock) {
34526
- for (const [fn, replacement] of Object.entries(FORMULA_ONLY)) {
34527
- const re = new RegExp(`\\b${fn}\\s*\\(\\s*\\)`, "g");
34528
- for (const m of execCode.matchAll(re)) {
35450
+ const declared = new Set(parsed.params.map((p) => p.name.toLowerCase()));
35451
+ const hasParamsBlock = parsed.blocks.some((b) => b.kind === "params");
35452
+ for (const ref of parsed.globalRefs) {
35453
+ if (ref.global !== "params") continue;
35454
+ if (!hasParamsBlock || declared.has(ref.property.toLowerCase())) continue;
35455
+ add(
35456
+ ref.span,
35457
+ `"${ref.property}" is not declared in the params: block.`,
35458
+ "error",
35459
+ "dsl/unknown-param"
35460
+ );
35461
+ }
35462
+ if (action) {
35463
+ const mode = action.executionMode ?? "single";
35464
+ const recordsSpan = parsed.locals.has("records") ? void 0 : parsed.recordsRefs[0];
35465
+ const bareFields = parsed.fieldRefs.filter(
35466
+ (r) => r.block === "execute" && r.isCurrentRecord && !parsed.locals.has(r.name) && !LITERAL_NAMES.has(r.name)
35467
+ );
35468
+ if (mode === "batch") {
35469
+ for (const field of bareFields) {
34529
35470
  add(
35471
+ field.outerSpan,
35472
+ `Action "${action.code}" runs in batch mode, which has no current record. Iterate instead: for x in records { x[${field.name}] }.`,
34530
35473
  "error",
34531
- "execute-formula-date",
34532
- `the execute: block calls ${fn}(), which is undefined at runtime \u2014 install fails with "'${fn}' is not defined". Use ${replacement} in execute:. ${fn}() is formula-only (canExecute:, formula columns).`,
34533
- lineOf(src, execOffset + (m.index ?? 0))
35474
+ "dsl/batch-bare-field"
34534
35475
  );
34535
35476
  }
34536
35477
  }
34537
- for (const m of execCode.matchAll(/\buserId\s*\(/g)) {
35478
+ if (mode !== "batch" && recordsSpan) {
34538
35479
  add(
34539
- "error",
34540
- "user-id-called-as-function",
34541
- "'userId' is a value, not a function \u2014 write 'userId' without parentheses, or 'currentUserId()'.",
34542
- lineOf(src, execOffset + (m.index ?? 0))
35480
+ recordsSpan,
35481
+ `"records" only exists in batch mode; action "${action.code}" runs in "${mode}" mode.`,
35482
+ "warning",
35483
+ "dsl/records-outside-batch"
34543
35484
  );
34544
35485
  }
34545
35486
  }
34546
- const recordRefs = [...code2.matchAll(/(?<![\w\]$])\[([a-z][a-z0-9_]*)\]/g)];
34547
- const mode = (opts.executionMode ?? "").toLowerCase();
34548
- if (opts.viaJob && recordRefs.length > 0) {
34549
- const first = recordRefs[0];
35487
+ for (const token of parsed.tokens) {
35488
+ if (blockOf(parsed, token.start) !== "canExecute") continue;
35489
+ if (token.text !== "&&" && token.text !== "||") continue;
34550
35490
  add(
34551
- "error",
34552
- "job-record-context",
34553
- `uses record-context syntax [${first[1]}] but is invoked by a scheduled job \u2014 jobs run as the system user with NO current record, so [field] is a hard error. Fetch rows with select() (or query()) inside the job action instead.`,
34554
- lineOf(src, first.index ?? 0)
35491
+ { start: token.start, end: token.end },
35492
+ `canExecute is a formula, not JavaScript \u2014 write ${token.text === "&&" ? "AND" : "OR"}.`,
35493
+ "warning",
35494
+ "dsl/js-in-formula"
34555
35495
  );
34556
35496
  }
34557
- if (mode === "batch" && execBlock) {
34558
- const inExec = recordRefs.filter(
34559
- (m) => (m.index ?? 0) >= execBlock.start && (m.index ?? 0) < execBlock.end
34560
- );
34561
- if (inExec.length > 0) {
35497
+ for (const call of parsed.calls) {
35498
+ if (call.name === "userId") {
34562
35499
  add(
35500
+ call.nameSpan,
35501
+ "'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.",
34563
35502
  "error",
34564
- "batch-record-context",
34565
- `executionMode is 'batch' but execute: uses record-context [${inExec[0][1]}] \u2014 in batch mode there is no current record. Iterate the selection instead: \`for rec in records { rec[field] }\`.`,
34566
- lineOf(src, inExec[0].index ?? 0)
35503
+ "dsl/user-id-call"
34567
35504
  );
35505
+ continue;
35506
+ }
35507
+ const builtin = BUILTIN_BY_NAME.get(call.name);
35508
+ if (!builtin || builtin.entityArg === void 0) continue;
35509
+ const arg = call.firstStringArg;
35510
+ if (!arg) continue;
35511
+ if (arg.interpolated) continue;
35512
+ if (arg.value.includes(".") || ctx.moduleCode?.includes("-")) continue;
35513
+ add(
35514
+ arg.span,
35515
+ `Qualify the entity code as "${ctx.moduleCode ? `${ctx.moduleCode}.` : "<module>."}${arg.value}" \u2014 entity_cd is only unique per module, so a bare code can resolve to another module's entity.`,
35516
+ "info",
35517
+ "dsl/unqualified-entity"
35518
+ );
35519
+ }
35520
+ let depth = 0;
35521
+ const functionDepths = [];
35522
+ const parenStack = [];
35523
+ let matchingParenOpen = -1;
35524
+ for (let i = 0; i < parsed.tokens.length; i++) {
35525
+ const token = parsed.tokens[i];
35526
+ if (token.text === "(") parenStack.push(i);
35527
+ else if (token.text === ")") matchingParenOpen = parenStack.pop() ?? -1;
35528
+ if (token.text === "{") {
35529
+ depth++;
35530
+ if (opensFunctionBody(parsed.tokens, i, matchingParenOpen))
35531
+ functionDepths.push(depth);
35532
+ } else if (token.text === "}") {
35533
+ if (functionDepths[functionDepths.length - 1] === depth)
35534
+ functionDepths.pop();
35535
+ depth = Math.max(0, depth - 1);
35536
+ }
35537
+ if (depth === 0 && token.character > 0) {
35538
+ const label = blockLabelAt(parsed.tokens, i);
35539
+ const isParamDecl = parsed.params.some(
35540
+ (p) => p.nameSpan.start === token.start
35541
+ );
35542
+ if (label && !isParamDecl) {
35543
+ add(
35544
+ label.span,
35545
+ `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.`,
35546
+ "error",
35547
+ "dsl/indented-block-header"
35548
+ );
35549
+ }
34568
35550
  }
35551
+ if (token.kind !== "ident" || token.text !== "return") continue;
35552
+ if (parsed.tokens[i - 1]?.text === ".") continue;
35553
+ if (parsed.tokens[i + 1]?.text === ":") continue;
35554
+ if (isMethodName(parsed.tokens, i)) continue;
35555
+ if (functionDepths.length > 0) continue;
35556
+ const block = blockOf(parsed, token.start);
35557
+ if (block !== "execute" && block !== "onBeforeStart") continue;
35558
+ add(
35559
+ { start: token.start, end: token.end },
35560
+ `A return outside a function is a syntax error \u2014 ${block}: compiles to a bare script. Use exit(message, level) to stop early and keep the changes made so far.`,
35561
+ "error",
35562
+ "dsl/top-level-return"
35563
+ );
35564
+ }
35565
+ for (const call of parsed.calls) {
35566
+ const replacement = FORMULA_ONLY.get(call.name);
35567
+ if (!replacement) continue;
35568
+ if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
35569
+ add(
35570
+ call.nameSpan,
35571
+ `${call.name}() is formula-only \u2014 in ${call.block}: it is undefined and install fails with "'${call.name}' is not defined". Write ${replacement}.`,
35572
+ "error",
35573
+ "dsl/formula-only-function"
35574
+ );
34569
35575
  }
34570
- for (const { sql, index, end } of sqlLiterals(src)) {
34571
- const bad = sql.match(/(?<![:\w]):([a-z][a-z0-9_]*)/i);
35576
+ const tokenAt = new Map(parsed.tokens.map((t, i) => [t.start, i]));
35577
+ for (const call of parsed.calls) {
35578
+ if (call.name !== "query" && call.name !== "callProc") continue;
35579
+ const arg = call.firstStringArg;
35580
+ if (!arg) continue;
35581
+ const bad = /(^|[^:\w]):([a-z][a-z0-9_]*)/i.exec(stripSqlNoise(arg.value));
34572
35582
  if (bad) {
35583
+ const name = bad[2];
34573
35584
  add(
35585
+ arg.span,
35586
+ `SQL binds @name, not :name \u2014 rewrite ':${name}' as '@${name}' and pass { ${name}: value } as the params argument.`,
34574
35587
  "error",
34575
- "sql-placeholder",
34576
- `SQL uses ':${bad[1]}' as a placeholder \u2014 dForge binds '@${bad[1]}'. Rewrite as '@${bad[1]}' and pass { ${bad[1]}: value } as the params argument.`,
34577
- lineOf(src, index)
35588
+ "dsl/sql-placeholder"
34578
35589
  );
34579
35590
  }
34580
- const after = src.slice(end, end + 40);
34581
- if (/^\s*\+/.test(after)) {
35591
+ if (arg.interpolated || parsed.tokens[arg.endIndex + 1]?.text === "+") {
35592
+ const built = arg.interpolated ? "${\u2026} interpolation" : "concatenation";
34582
35593
  add(
35594
+ arg.span,
35595
+ // callProc's first argument is the procedure name, and an
35596
+ // identifier cannot be bound — the @placeholder advice cannot
35597
+ // be followed there.
35598
+ 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.`,
34583
35599
  "warning",
34584
- "sql-concat",
34585
- "SQL string looks concatenated \u2014 build queries with @placeholders + a params object, never by string concatenation.",
34586
- lineOf(src, index)
35600
+ "dsl/sql-concat"
34587
35601
  );
34588
35602
  }
34589
35603
  }
34590
- if (execBlock) {
34591
- const declaresFunction = /\bfunction\b/.test(execCode);
34592
- const returnRe = declaresFunction ? /^[ \t]{0,8}return\b/gm : /\breturn\b/g;
34593
- for (const m of execCode.matchAll(returnRe)) {
35604
+ const declaredFunctions = /* @__PURE__ */ new Map();
35605
+ for (let i = 0; i < parsed.tokens.length; i++) {
35606
+ const token = parsed.tokens[i];
35607
+ if (token.text !== "function") continue;
35608
+ const name = parsed.tokens[i + 1];
35609
+ if (name?.kind !== "ident") continue;
35610
+ const blocks = declaredFunctions.get(name.text) ?? /* @__PURE__ */ new Set();
35611
+ blocks.add(blockOf(parsed, token.start));
35612
+ declaredFunctions.set(name.text, blocks);
35613
+ }
35614
+ const isDeclaredIn = (name, block) => {
35615
+ const blocks = declaredFunctions.get(name);
35616
+ return blocks !== void 0 && (blocks.has(null) || blocks.has(block));
35617
+ };
35618
+ const reportedUnknown = /* @__PURE__ */ new Set();
35619
+ for (const call of parsed.calls) {
35620
+ if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
35621
+ const name = call.name;
35622
+ const idx = tokenAt.get(call.nameSpan.start);
35623
+ if (idx !== void 0) {
35624
+ const prev = parsed.tokens[idx - 1]?.text;
35625
+ if (prev === "function" || prev === "new") continue;
35626
+ if (isMethodName(parsed.tokens, idx)) continue;
35627
+ }
35628
+ 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.
35629
+ name === "userId" || isDeclaredIn(name, call.block) || parsed.locals.has(name) || reportedUnknown.has(name)) {
35630
+ continue;
35631
+ }
35632
+ reportedUnknown.add(name);
35633
+ add(
35634
+ call.nameSpan,
35635
+ `"${name}()" is not a DSL host function, and nothing in this script declares it. Check it against the built-in catalog.`,
35636
+ "warning",
35637
+ "dsl/unknown-builtin"
35638
+ );
35639
+ }
35640
+ if (ctx.action?.viaJob) {
35641
+ const inScript = (block) => block === "execute" || block === "onBeforeStart";
35642
+ const bound = [];
35643
+ for (const ref of parsed.fieldRefs) {
35644
+ if (!inScript(ref.block)) continue;
35645
+ if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
35646
+ continue;
35647
+ if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
35648
+ bound.push({ span: ref.outerSpan, read: `[${ref.name}]` });
35649
+ }
35650
+ for (const ref of parsed.globalRefs) {
35651
+ if (ref.global !== "old" || !inScript(ref.block)) continue;
35652
+ bound.push({ span: ref.span, read: `old[${ref.property}]` });
35653
+ }
35654
+ if (!parsed.locals.has("records")) {
35655
+ for (const span of parsed.recordsRefs) {
35656
+ if (!inScript(blockOf(parsed, span.start))) continue;
35657
+ bound.push({ span, read: "records" });
35658
+ }
35659
+ }
35660
+ const first = bound.sort((a, b) => a.span.start - b.span.start)[0];
35661
+ if (first) {
34594
35662
  add(
35663
+ first.span,
35664
+ `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.`,
34595
35665
  "error",
34596
- "top-level-return",
34597
- "top-level `return` is invalid in a DSL body. Use exit('msg', 'info') to stop and commit, or error('msg') to stop and roll back.",
34598
- lineOf(src, execOffset + (m.index ?? 0))
35666
+ "dsl/job-record-context"
34599
35667
  );
34600
35668
  }
34601
35669
  }
34602
- const local = /* @__PURE__ */ new Set();
34603
- for (const m of code2.matchAll(/\bfunction\s+([A-Za-z_$][\w$]*)/g)) local.add(m[1]);
34604
- for (const m of code2.matchAll(/\bvar\s+([A-Za-z_$][\w$]*)/g)) local.add(m[1]);
34605
- const reported = /* @__PURE__ */ new Set();
34606
- for (const m of code2.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
34607
- const fn = m[1];
34608
- if (DSL_BUILTINS.has(fn) || JS_RESERVED.has(fn) || local.has(fn) || reported.has(fn) || fn in FORMULA_ONLY || VALUE_MEMBERS.has(fn)) {
35670
+ out.push(...inlineAssignmentIssues(parsed, positionAt));
35671
+ return out;
35672
+ }
35673
+ function columnMatcher(columns) {
35674
+ if (!columns) return () => true;
35675
+ if (typeof columns.keys !== "function")
35676
+ return (name) => columns.has(name) || columns.has(name.toLowerCase());
35677
+ const folded = /* @__PURE__ */ new Set();
35678
+ for (const key of columns.keys()) folded.add(key.toLowerCase());
35679
+ return (name) => folded.has(name.toLowerCase());
35680
+ }
35681
+ function isMethodName(tokens, i) {
35682
+ if (tokens[i + 1]?.text !== "(") return false;
35683
+ let depth = 0;
35684
+ for (let j = i + 1; j < tokens.length; j++) {
35685
+ const text = tokens[j].text;
35686
+ if (text === "(") depth++;
35687
+ else if (text === ")") {
35688
+ depth--;
35689
+ if (depth === 0) return tokens[j + 1]?.text === "{";
35690
+ }
35691
+ }
35692
+ return false;
35693
+ }
35694
+ function opensFunctionBody(tokens, i, matchingParenOpen) {
35695
+ const prev = tokens[i - 1];
35696
+ if (prev?.text === "=>") return true;
35697
+ if (prev?.text !== ")" || matchingParenOpen < 0) return false;
35698
+ const head = tokens[matchingParenOpen - 1];
35699
+ return head?.kind === "ident" && !CONTROL_KEYWORDS.has(head.text);
35700
+ }
35701
+ var BLOCK_STOPS = {
35702
+ params: /* @__PURE__ */ new Set(["schema", "canExecute", "onBeforeStart", "execute"]),
35703
+ canExecute: /* @__PURE__ */ new Set(["schema", "onBeforeStart", "execute"]),
35704
+ schema: /* @__PURE__ */ new Set(["params", "canExecute", "onBeforeStart", "execute"]),
35705
+ onBeforeStart: /* @__PURE__ */ new Set(["schema", "execute"]),
35706
+ execute: /* @__PURE__ */ new Set()
35707
+ };
35708
+ var FORMULA_ONLY = /* @__PURE__ */ new Map([
35709
+ ["TODAY", "now()"],
35710
+ ["NOW", "now()"],
35711
+ ["CURRENT_USER_ID", "currentUserId()"]
35712
+ ]);
35713
+ var KEYWORDS_BEFORE_PAREN = /* @__PURE__ */ new Set([
35714
+ "function",
35715
+ "return",
35716
+ "typeof",
35717
+ "instanceof",
35718
+ "new",
35719
+ "delete",
35720
+ "void",
35721
+ "in",
35722
+ "of",
35723
+ "do",
35724
+ "else",
35725
+ "try",
35726
+ "throw",
35727
+ "yield",
35728
+ "await"
35729
+ ]);
35730
+ var JS_GLOBALS = /* @__PURE__ */ new Set([
35731
+ "parseInt",
35732
+ "parseFloat",
35733
+ "isNaN",
35734
+ "isFinite",
35735
+ "encodeURI",
35736
+ "decodeURI",
35737
+ "encodeURIComponent",
35738
+ "decodeURIComponent",
35739
+ "String",
35740
+ "Number",
35741
+ "Boolean",
35742
+ "Array",
35743
+ "Object",
35744
+ "Date",
35745
+ "Math",
35746
+ "JSON",
35747
+ "RegExp",
35748
+ "Error",
35749
+ "Set",
35750
+ "Map",
35751
+ "WeakSet",
35752
+ "WeakMap",
35753
+ "Promise",
35754
+ "Symbol",
35755
+ "BigInt"
35756
+ ]);
35757
+ var LITERAL_NAMES = /* @__PURE__ */ new Set([
35758
+ "true",
35759
+ "false",
35760
+ "null",
35761
+ "TRUE",
35762
+ "FALSE",
35763
+ "NULL"
35764
+ ]);
35765
+ function stripSqlNoise(sql) {
35766
+ let out = "";
35767
+ let i = 0;
35768
+ while (i < sql.length) {
35769
+ const c = sql[i];
35770
+ if (c === "'" || c === '"') {
35771
+ const quote = c;
35772
+ out += " ";
35773
+ i++;
35774
+ while (i < sql.length) {
35775
+ if (sql[i] === quote && sql[i + 1] === quote) {
35776
+ out += " ";
35777
+ i += 2;
35778
+ continue;
35779
+ }
35780
+ if (sql[i] === quote) break;
35781
+ out += sql[i] === "\n" ? "\n" : " ";
35782
+ i++;
35783
+ }
35784
+ if (i < sql.length) {
35785
+ out += " ";
35786
+ i++;
35787
+ }
34609
35788
  continue;
34610
35789
  }
34611
- reported.add(fn);
34612
- add(
34613
- "warning",
34614
- "unknown-builtin",
34615
- `calls '${fn}()', which is not a DSL host function or a locally declared one. Check the name against dforge://docs/dsl (Built-in functions).`,
34616
- lineOf(src, m.index ?? 0)
34617
- );
35790
+ if (c === "-" && sql[i + 1] === "-") {
35791
+ while (i < sql.length && sql[i] !== "\n") {
35792
+ out += " ";
35793
+ i++;
35794
+ }
35795
+ continue;
35796
+ }
35797
+ if (c === "/" && sql[i + 1] === "*") {
35798
+ out += " ";
35799
+ i += 2;
35800
+ while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
35801
+ out += sql[i] === "\n" ? "\n" : " ";
35802
+ i++;
35803
+ }
35804
+ if (i < sql.length) {
35805
+ out += " ";
35806
+ i += 2;
35807
+ }
35808
+ continue;
35809
+ }
35810
+ out += c;
35811
+ i++;
35812
+ }
35813
+ return out;
35814
+ }
35815
+ function blockOf(parsed, offset) {
35816
+ return parsed.blocks.find((b) => offset >= b.body.start && offset < b.body.end)?.kind ?? null;
35817
+ }
35818
+ function inlineAssignmentIssues(parsed, positionAt) {
35819
+ const out = [];
35820
+ for (const ref of parsed.fieldRefs) {
35821
+ const assign = parsed.tokens[ref.endIndex + 1];
35822
+ if (!assign || assign.kind !== "punct" || assign.text !== "=") continue;
35823
+ const line = parsed.tokens[ref.startIndex].line;
35824
+ let openedHere = false;
35825
+ for (let i = ref.startIndex - 1; i >= 0; i--) {
35826
+ const t = parsed.tokens[i];
35827
+ if (t.line !== line) break;
35828
+ if (t.text === "{") {
35829
+ openedHere = true;
35830
+ break;
35831
+ }
35832
+ }
35833
+ if (!openedHere) continue;
35834
+ const pos = positionAt(ref.outerSpan.start);
35835
+ out.push({
35836
+ rule: "dsl/inline-assignment",
35837
+ severity: "warning",
35838
+ message: "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.",
35839
+ start: ref.outerSpan.start,
35840
+ end: ref.outerSpan.end,
35841
+ line: pos.line,
35842
+ column: pos.column
35843
+ });
35844
+ }
35845
+ return out;
35846
+ }
35847
+ function lineIndexer(text) {
35848
+ const starts = [0];
35849
+ for (let i = 0; i < text.length; i++) {
35850
+ if (text[i] === "\n") starts.push(i + 1);
35851
+ }
35852
+ return (offset) => {
35853
+ let lo = 0;
35854
+ let hi = starts.length - 1;
35855
+ while (lo < hi) {
35856
+ const mid = lo + hi + 1 >> 1;
35857
+ if (starts[mid] <= offset) lo = mid;
35858
+ else hi = mid - 1;
35859
+ }
35860
+ return { line: lo + 1, column: offset - starts[lo] + 1 };
35861
+ };
35862
+ }
35863
+
35864
+ // src/tools/dsl-check.ts
35865
+ function checkDsl2(src, opts = {}) {
35866
+ const raw = opts.executionMode?.trim();
35867
+ const mode = raw?.toLowerCase();
35868
+ const executionMode = mode === "single" || mode === "each" || mode === "batch" ? mode : void 0;
35869
+ const ctx = {
35870
+ moduleCode: opts.moduleCode,
35871
+ action: executionMode || opts.viaJob || opts.actionCode ? { code: opts.actionCode ?? "this action", executionMode, viaJob: opts.viaJob } : void 0,
35872
+ currentEntity: opts.entity ?? null
35873
+ };
35874
+ const issues = checkDsl(src, ctx).map(toIssue);
35875
+ if (raw && !executionMode) {
35876
+ issues.unshift({
35877
+ level: "warning",
35878
+ rule: "action/unknown-execution-mode",
35879
+ message: `executionMode '${raw}' is not one of single, each, batch \u2014 the record-context rules stood down, so a \`[field]\` read that batch mode forbids would not be reported. Fix the value in ui/actions.json.`
35880
+ });
34618
35881
  }
34619
35882
  return issues;
34620
35883
  }
35884
+ function toIssue(issue2) {
35885
+ return {
35886
+ level: issue2.severity === "error" ? "error" : "warning",
35887
+ rule: issue2.rule,
35888
+ message: issue2.message,
35889
+ line: issue2.line
35890
+ };
35891
+ }
34621
35892
 
34622
35893
  // src/tools/action-add.ts
34623
35894
  var actionAddSchema = {
@@ -34640,7 +35911,19 @@ var actionAddSchema = {
34640
35911
  )
34641
35912
  };
34642
35913
  function actionAdd(args) {
34643
- const dslIssues = checkDsl(args.dslBody, { executionMode: args.executionMode });
35914
+ const { paths, manifest } = loadManifest(args.moduleDir);
35915
+ const { traits: localTraits, error: traitsError } = readLocalTraits(paths.root);
35916
+ if (traitsError) {
35917
+ throw new Error(
35918
+ `traits.json \u2014 ${traitsError} Its traits can't be overlaid, so '${args.entityCode}' reads as declaring an unknown trait and the column rules stand down \u2014 this body would be written unchecked against the entity. Fix the file and call again.`
35919
+ );
35920
+ }
35921
+ const dslIssues = checkDsl2(args.dslBody, {
35922
+ executionMode: args.executionMode,
35923
+ moduleCode: manifest.code,
35924
+ actionCode: args.code,
35925
+ entity: entityRecordContext(paths, manifest, args.entityCode, localTraits)
35926
+ });
34644
35927
  const dslErrors = dslIssues.filter((i) => i.level === "error");
34645
35928
  if (dslErrors.length > 0) {
34646
35929
  throw new Error(
@@ -34649,7 +35932,6 @@ function actionAdd(args) {
34649
35932
  );
34650
35933
  }
34651
35934
  const dslWarnings = dslIssues.filter((i) => i.level === "warning");
34652
- const { paths, manifest } = loadManifest(args.moduleDir);
34653
35935
  const actionsJson = readJsonOrDefault(paths.actions, {});
34654
35936
  if (Object.prototype.hasOwnProperty.call(actionsJson, args.code)) {
34655
35937
  throw new Error(
@@ -34698,6 +35980,9 @@ var actionCheckSchema = {
34698
35980
  ),
34699
35981
  viaJob: external_exports.boolean().optional().describe(
34700
35982
  "True when a scheduled job will fire this action \u2014 jobs have NO current record, so '[field]' becomes a hard error. Detected automatically from logic/jobs.json when actionCode is used."
35983
+ ),
35984
+ entityCode: external_exports.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/).optional().describe(
35985
+ "Entity the draft body acts on. With moduleDir, this turns on the column rules ('[field]' must be a real column, traits expanded) for a dslBody that isn't registered yet. Read from ui/actions.json when actionCode is used."
34701
35986
  )
34702
35987
  };
34703
35988
  function actionCheck(args) {
@@ -34705,37 +35990,63 @@ function actionCheck(args) {
34705
35990
  let mode = args.executionMode;
34706
35991
  let viaJob = args.viaJob ?? false;
34707
35992
  let label = "draft DSL body";
34708
- if (args.actionCode) {
34709
- if (!args.moduleDir) {
34710
- throw new Error("moduleDir is required when checking an on-disk action (actionCode).");
34711
- }
34712
- const { paths } = loadManifest(args.moduleDir);
34713
- const actions = readJsonOrDefault(paths.actions, {});
34714
- const entry = actions[args.actionCode];
34715
- if (!entry) {
34716
- throw new Error(
34717
- `Action '${args.actionCode}' is not in ui/actions.json. Registered actions: ${Object.keys(actions).join(", ") || "(none)"}.`
34718
- );
34719
- }
34720
- const script = entry.script ?? args.actionCode;
34721
- const dslPath = path10.join(paths.logicDir, "actions", `${script}.dsl`);
34722
- if (!fs7.existsSync(dslPath)) {
34723
- throw new Error(
34724
- `Action '${args.actionCode}' declares script '${script}' but logic/actions/${script}.dsl does not exist.`
34725
- );
34726
- }
34727
- body = fs7.readFileSync(dslPath, "utf8");
34728
- mode = mode ?? (entry.executionMode ?? entry.mode);
34729
- label = `logic/actions/${script}.dsl`;
34730
- if (args.viaJob === void 0) {
34731
- const jobFile = readJsonOrDefault(paths.jobs, {});
34732
- viaJob = (jobFile.jobs ?? []).some((j) => j.action === args.actionCode);
35993
+ let entityCode = args.entityCode;
35994
+ let moduleCode;
35995
+ let entity;
35996
+ const preIssues = [];
35997
+ if (args.actionCode && !args.moduleDir) {
35998
+ throw new Error("moduleDir is required when checking an on-disk action (actionCode).");
35999
+ }
36000
+ if (args.moduleDir) {
36001
+ const { paths, manifest } = loadManifest(args.moduleDir);
36002
+ moduleCode = manifest.code;
36003
+ if (args.actionCode) {
36004
+ const actions = readJsonOrDefault(paths.actions, {});
36005
+ const entry = actions[args.actionCode];
36006
+ if (!entry) {
36007
+ throw new Error(
36008
+ `Action '${args.actionCode}' is not in ui/actions.json. Registered actions: ${Object.keys(actions).join(", ") || "(none)"}.`
36009
+ );
36010
+ }
36011
+ const script = entry.script ?? args.actionCode;
36012
+ const dslPath = path10.join(paths.logicDir, "actions", `${script}.dsl`);
36013
+ if (!fs7.existsSync(dslPath)) {
36014
+ throw new Error(
36015
+ `Action '${args.actionCode}' declares script '${script}' but logic/actions/${script}.dsl does not exist.`
36016
+ );
36017
+ }
36018
+ body = fs7.readFileSync(dslPath, "utf8");
36019
+ mode = mode ?? (entry.executionMode ?? entry.mode);
36020
+ entityCode = entityCode ?? entry.entityCode;
36021
+ label = `logic/actions/${script}.dsl`;
36022
+ if (args.viaJob === void 0) {
36023
+ const jobFile = readJsonOrDefault(paths.jobs, {});
36024
+ viaJob = (jobFile.jobs ?? []).some((j) => j.action === args.actionCode);
36025
+ }
36026
+ }
36027
+ const { traits: localTraits, error: traitsError } = readLocalTraits(paths.root);
36028
+ if (traitsError) {
36029
+ preIssues.push({
36030
+ level: "error",
36031
+ rule: "module/invalid-traits-json",
36032
+ message: `traits.json \u2014 ${traitsError} This module's own traits can't be overlaid on the platform ones, so an entity using them reads as declaring an unknown trait and the column rules below stand down. Fix the file, then check again.`
36033
+ });
34733
36034
  }
36035
+ entity = entityRecordContext(paths, manifest, entityCode, localTraits);
34734
36036
  }
34735
36037
  if (body === void 0) {
34736
36038
  throw new Error("Pass either dslBody (a draft) or actionCode (an action already on disk).");
34737
36039
  }
34738
- const issues = checkDsl(body, { executionMode: mode, viaJob });
36040
+ const issues = [
36041
+ ...preIssues,
36042
+ ...checkDsl2(body, {
36043
+ executionMode: mode,
36044
+ viaJob,
36045
+ moduleCode,
36046
+ actionCode: args.actionCode,
36047
+ entity
36048
+ })
36049
+ ];
34739
36050
  const errors = issues.filter((i) => i.level === "error");
34740
36051
  const warnings = issues.filter((i) => i.level === "warning");
34741
36052
  const summary = errors.length === 0 && warnings.length === 0 ? `\u2713 ${label}: no DSL issues${mode ? ` (executionMode '${mode}'${viaJob ? ", job-invoked" : ""})` : ""}.` : `${label}: ${errors.length} error(s), ${warnings.length} warning(s).${errors.length ? ` First: ${errors[0].message}` : ""}`;
@@ -34946,6 +36257,12 @@ function buildSkeleton(moduleDir) {
34946
36257
  const { paths, manifest } = loadManifest(moduleDir);
34947
36258
  const entries = [];
34948
36259
  const counts = {};
36260
+ const { traits: localTraits, error: traitsError } = readLocalTraits(paths.root);
36261
+ if (traitsError) {
36262
+ throw new Error(
36263
+ `traits.json \u2014 ${traitsError} Its traits can't be overlaid, so the entities that use one would be skipped as declaring an unknown trait and their columns would go unlabelled \u2014 which fails the install. Fix the file and sync again.`
36264
+ );
36265
+ }
34949
36266
  const push = (section, pathParts, english, required2 = false) => {
34950
36267
  entries.push({ pathParts, english, required: required2 });
34951
36268
  counts[section] = (counts[section] ?? 0) + 1;
@@ -34967,8 +36284,8 @@ function buildSkeleton(moduleDir) {
34967
36284
  push("entities", ["entities", name, "label"], str(e, "description") ?? titleize(name));
34968
36285
  const authored = e.fields ?? {};
34969
36286
  const traitCodes = e.traits ?? [];
34970
- assertKnownTraits(traitCodes, name);
34971
- const traitFields = expandTraits(traitCodes, name);
36287
+ assertKnownTraits(traitCodes, name, localTraits);
36288
+ const traitFields = expandTraits(traitCodes, name, localTraits);
34972
36289
  const all = { ...traitFields, ...authored };
34973
36290
  for (const [fname, f] of Object.entries(all)) {
34974
36291
  push(
@@ -35157,21 +36474,27 @@ function seedAdd(args) {
35157
36474
  );
35158
36475
  }
35159
36476
  }
35160
- assertKnownTraits(traits2, args.entity);
35161
- const traitFields = expandTraits(traits2, args.entity);
35162
- const known = /* @__PURE__ */ new Set([...Object.keys(fields), ...Object.keys(traitFields)]);
36477
+ const { traits: localTraits, error: traitsError } = readLocalTraits(paths.root);
36478
+ if (traitsError) {
36479
+ throw new Error(
36480
+ `traits.json \u2014 ${traitsError} Its traits can't be overlaid, so an entity that uses one reads as declaring an unknown trait and its columns as unknown. Fix the file.`
36481
+ );
36482
+ }
36483
+ assertKnownTraits(traits2, args.entity, localTraits);
36484
+ const { columns: known, fieldDefs: allFields } = expandedEntity(entity, args.entity, localTraits);
36485
+ const traitOnly = [...known].filter((c) => !(c in fields));
35163
36486
  known.add(pk);
35164
36487
  const unknown2 = /* @__PURE__ */ new Set();
35165
36488
  for (const r of args.records) {
35166
36489
  for (const k of Object.keys(r)) if (!known.has(k)) unknown2.add(k);
35167
36490
  }
35168
36491
  if (unknown2.size > 0) {
35169
- const traitCols = Object.keys(traitFields);
36492
+ const traitCols = traitOnly;
35170
36493
  throw new Error(
35171
36494
  `Seed records set column(s) not defined on '${args.entity}': ${[...unknown2].join(", ")}. Its columns are: ${Object.keys(fields).join(", ") || "(none authored)"}` + (traitCols.length > 0 ? `, plus these from traits [${traits2.join(", ")}]: ${traitCols.join(", ")}.` : ".")
35172
36495
  );
35173
36496
  }
35174
- for (const [fname, f] of Object.entries(fields)) {
36497
+ for (const [fname, f] of Object.entries(allFields)) {
35175
36498
  if (f?.columnType !== "R") continue;
35176
36499
  const used = args.records.some((r) => r[fname] !== void 0);
35177
36500
  if (used) {
@@ -35181,7 +36504,7 @@ function seedAdd(args) {
35181
36504
  );
35182
36505
  }
35183
36506
  }
35184
- for (const [fname, f] of Object.entries(fields)) {
36507
+ for (const [fname, f] of Object.entries(allFields)) {
35185
36508
  const flags = typeof f?.flags === "string" ? f.flags : "";
35186
36509
  const isVirtual = f?.columnType === "R" || f?.columnType === "S" || f?.columnType === "F" || f?.columnType === "G";
35187
36510
  if (isVirtual || !flags.includes("M") || f?.formula !== void 0) continue;
@@ -35706,9 +37029,17 @@ function moduleValidate(args) {
35706
37029
  const err = (where, message) => issues.push({ level: "error", where, message });
35707
37030
  const warn = (where, message) => issues.push({ level: "warning", where, message });
35708
37031
  const entityMap = manifest.entities ?? {};
37032
+ const { traits: localTraits, error: traitsError } = readLocalTraits(paths.root);
37033
+ if (traitsError) {
37034
+ err(
37035
+ "traits.json",
37036
+ `${traitsError} This module's own traits can't be overlaid on the platform ones, so entities using them read as declaring an unknown trait \u2014 expect knock-on 'not a column' errors below.`
37037
+ );
37038
+ }
35709
37039
  const entities = {};
35710
37040
  const columnsOf = {};
35711
37041
  const fieldDefsOf = {};
37042
+ const partialColumns = /* @__PURE__ */ new Set();
35712
37043
  for (const [name, relPath] of Object.entries(entityMap)) {
35713
37044
  if (name.includes(".")) continue;
35714
37045
  const abs = path15.join(paths.root, relPath.replace(/^\.\//, ""));
@@ -35724,20 +37055,19 @@ function moduleValidate(args) {
35724
37055
  continue;
35725
37056
  }
35726
37057
  entities[name] = e;
35727
- const fields = e.fields ?? {};
35728
- const cols = new Set(Object.keys(fields));
35729
37058
  const traits2 = e.traits ?? [];
35730
- const badTraits = unknownTraits(traits2);
37059
+ const badTraits = unknownTraits(traits2, localTraits);
35731
37060
  if (badTraits.length > 0) {
35732
37061
  err(
35733
37062
  `entities/${name}.json`,
35734
37063
  `declares unknown trait(s): ${badTraits.join(", ")}. Valid: ${TRAIT_CODES.join(", ")}. An unrecognized trait is ignored when columns are expanded, so its columns are missing from this entity \u2014 expect knock-on 'not a column' errors below.`
35735
37064
  );
37065
+ partialColumns.add(name);
35736
37066
  }
35737
- const traitFields = expandTraits(traits2, name);
35738
- for (const c of Object.keys(traitFields)) cols.add(c);
35739
- columnsOf[name] = cols;
35740
- fieldDefsOf[name] = { ...traitFields, ...fields };
37067
+ const { columns, fieldDefs, conflicts } = expandedEntity(e, name, localTraits);
37068
+ for (const c of conflicts) err(`entities/${name}.json`, describeTraitConflict(c));
37069
+ columnsOf[name] = columns;
37070
+ fieldDefsOf[name] = fieldDefs;
35741
37071
  }
35742
37072
  const deps = new Set(Object.keys(manifest.dependencies ?? {}));
35743
37073
  const isKnownEntity = (code2) => {
@@ -36108,7 +37438,8 @@ function moduleValidate(args) {
36108
37438
  err(where, `schedules action '${act}', which is not in ui/actions.json.`);
36109
37439
  }
36110
37440
  if (act && act in actions) {
36111
- const script = actions[act]?.script;
37441
+ const registered = actions[act] ?? {};
37442
+ const script = registered.script;
36112
37443
  if (typeof script === "string") {
36113
37444
  const dslPath = path15.join(paths.logicDir, "actions", `${script}.dsl`);
36114
37445
  if (fs12.existsSync(dslPath)) {
@@ -36117,8 +37448,13 @@ function moduleValidate(args) {
36117
37448
  body = fs12.readFileSync(dslPath, "utf8");
36118
37449
  } catch {
36119
37450
  }
36120
- for (const issue2 of checkDsl(body, { viaJob: true })) {
36121
- if (issue2.rule !== "job-record-context") continue;
37451
+ for (const issue2 of checkDsl2(body, {
37452
+ executionMode: registered.executionMode ?? registered.mode,
37453
+ viaJob: true,
37454
+ actionCode: act,
37455
+ moduleCode: manifest.code
37456
+ })) {
37457
+ if (issue2.rule !== "dsl/job-record-context") continue;
36122
37458
  issues.push({ level: issue2.level, where, message: issue2.message });
36123
37459
  }
36124
37460
  }
@@ -36147,10 +37483,20 @@ function moduleValidate(args) {
36147
37483
  continue;
36148
37484
  }
36149
37485
  const mode = a.executionMode ?? a.mode;
36150
- for (const issue2 of checkDsl(body, { executionMode: mode })) {
37486
+ const entityCode = localEntityCode(
37487
+ a.entityCode,
37488
+ manifest.code
37489
+ );
37490
+ const columns = entityCode && !partialColumns.has(entityCode) ? columnsOf[entityCode] : void 0;
37491
+ for (const issue2 of checkDsl2(body, {
37492
+ executionMode: mode,
37493
+ actionCode: acode,
37494
+ moduleCode: manifest.code,
37495
+ entity: entityCode && columns ? { qualified: `${manifest.code}.${entityCode}`, columns } : void 0
37496
+ })) {
36151
37497
  issues.push({
36152
37498
  level: issue2.level,
36153
- where: `logic/actions/${script}.dsl`,
37499
+ where: `logic/actions/${script}.dsl${issue2.line ? `:${issue2.line}` : ""}`,
36154
37500
  message: `[${acode}] ${issue2.message}`
36155
37501
  });
36156
37502
  }
@@ -36756,7 +38102,7 @@ var resources = [
36756
38102
  matrixExample("ui/data_views.json", "Canonical MATRIX data view: viewType 'matrix' + viewConfig with a dataset rowAxis (categories), a dropdown colAxis ('budget_line.quarter'), and an editable cell mapping rowKey/colKey/fields onto the budget_line entity."),
36757
38103
  matrixExample("entities/budget_line.json", "Matrix CELL entity: one record per (category, quarter) \u2014 FK+Reference to the row-axis entity plus the dropdown column that is the col axis, with the editable 'amount' value column."),
36758
38104
  matrixExample("entities/budget_category.json", "Matrix row-axis (dataset) entity."),
36759
- matrixExample("manifest.json", "Matrix example manifest (entities + dataViews + roles + seed)."),
38105
+ matrixExample("manifest.json", "Matrix example manifest: declares only 'entities' \u2014 the data view, roles and seed files are found by convention (ui/, security/, seed-data/), and the manifest schema is additionalProperties:false."),
36760
38106
  matrixExample("security/roles.json", "Matrix example roles: S on the axis entity, SIUD on the editable cell entity."),
36761
38107
  matrixExample("seed-data/01-categories.json", "Matrix example seed: row-axis category records."),
36762
38108
  columnSecurityExample("entities/product.json", `Canonical ENTITY VIEWS (column-level security): one entity, two views \u2014 'storekeeper' (a plain column subset, prices absent entirely) and 'accountant' (with a per-column flags override making a column read-only in that view only, and a per-view formula override on an "F" column). Both list the primary key, which a view must.`),