isc-transforms-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/JSONS/authoritative-operation-catalog.json +280 -0
  2. package/JSONS/sailpoint.isc.transforms.accountAttribute.schema.json +164 -0
  3. package/JSONS/sailpoint.isc.transforms.base64Decode.schema.json +37 -0
  4. package/JSONS/sailpoint.isc.transforms.base64Encode.schema.json +32 -0
  5. package/JSONS/sailpoint.isc.transforms.concat.schema.json +109 -0
  6. package/JSONS/sailpoint.isc.transforms.conditional.schema.json +161 -0
  7. package/JSONS/sailpoint.isc.transforms.dateCompare.schema.json +159 -0
  8. package/JSONS/sailpoint.isc.transforms.dateFormat.schema.json +101 -0
  9. package/JSONS/sailpoint.isc.transforms.dateMath.schema.json +119 -0
  10. package/JSONS/sailpoint.isc.transforms.decomposeDiacriticalMarks.schema.json +92 -0
  11. package/JSONS/sailpoint.isc.transforms.displayName.schema.json +42 -0
  12. package/JSONS/sailpoint.isc.transforms.e164phone.schema.json +107 -0
  13. package/JSONS/sailpoint.isc.transforms.firstValid.schema.json +129 -0
  14. package/JSONS/sailpoint.isc.transforms.generateRandomString.schema.json +94 -0
  15. package/JSONS/sailpoint.isc.transforms.getEndOfString.schema.json +118 -0
  16. package/JSONS/sailpoint.isc.transforms.getReferenceIdentityAttribute.schema.json +79 -0
  17. package/JSONS/sailpoint.isc.transforms.identityAttribute.schema.json +104 -0
  18. package/JSONS/sailpoint.isc.transforms.index.schema.json +48 -0
  19. package/JSONS/sailpoint.isc.transforms.indexOf.schema.json +90 -0
  20. package/JSONS/sailpoint.isc.transforms.iso3166.schema.json +103 -0
  21. package/JSONS/sailpoint.isc.transforms.join.schema.json +113 -0
  22. package/JSONS/sailpoint.isc.transforms.lastIndexOf.schema.json +90 -0
  23. package/JSONS/sailpoint.isc.transforms.leftPad.schema.json +96 -0
  24. package/JSONS/sailpoint.isc.transforms.lookup.schema.json +100 -0
  25. package/JSONS/sailpoint.isc.transforms.lower.schema.json +80 -0
  26. package/JSONS/sailpoint.isc.transforms.normalizeNames.schema.json +79 -0
  27. package/JSONS/sailpoint.isc.transforms.randomAlphaNumeric.schema.json +53 -0
  28. package/JSONS/sailpoint.isc.transforms.randomNumeric.schema.json +53 -0
  29. package/JSONS/sailpoint.isc.transforms.reference.schema.json +90 -0
  30. package/JSONS/sailpoint.isc.transforms.replace.schema.json +96 -0
  31. package/JSONS/sailpoint.isc.transforms.replaceAll.schema.json +96 -0
  32. package/JSONS/sailpoint.isc.transforms.rfc5646.schema.json +79 -0
  33. package/JSONS/sailpoint.isc.transforms.rightPad.schema.json +96 -0
  34. package/JSONS/sailpoint.isc.transforms.rule.schema.json +106 -0
  35. package/JSONS/sailpoint.isc.transforms.split.schema.json +103 -0
  36. package/JSONS/sailpoint.isc.transforms.static.schema.json +131 -0
  37. package/JSONS/sailpoint.isc.transforms.substring.schema.json +167 -0
  38. package/JSONS/sailpoint.isc.transforms.trim.schema.json +93 -0
  39. package/JSONS/sailpoint.isc.transforms.upper.schema.json +80 -0
  40. package/JSONS/sailpoint.isc.transforms.usernameGenerator.schema.json +106 -0
  41. package/JSONS/sailpoint.isc.transforms.uuid.schema.json +32 -0
  42. package/LICENSE +21 -0
  43. package/README.md +221 -0
  44. package/bin/isc-transforms-mcp.mjs +3 -0
  45. package/dist/allowlist.js +37 -0
  46. package/dist/config.js +67 -0
  47. package/dist/http/errors.js +19 -0
  48. package/dist/http/iscAuth.js +45 -0
  49. package/dist/http/iscClient.js +73 -0
  50. package/dist/index.js +613 -0
  51. package/dist/logger.js +9 -0
  52. package/dist/redact.js +28 -0
  53. package/dist/transforms/catalog.js +566 -0
  54. package/dist/transforms/explain.js +266 -0
  55. package/dist/transforms/generate.js +551 -0
  56. package/dist/transforms/index.js +9 -0
  57. package/dist/transforms/lint.js +839 -0
  58. package/dist/transforms/normalize.js +96 -0
  59. package/dist/transforms/patterns.js +295 -0
  60. package/dist/transforms/testcases.js +350 -0
  61. package/dist/transforms/validate.js +250 -0
  62. package/dist/util/diff.js +23 -0
  63. package/package.json +76 -0
@@ -0,0 +1,839 @@
1
+ // src/transforms/lint.ts
2
+ // Strict SailPoint ISC transform linter — aligned with official docs and per-type JSON schemas.
3
+ //
4
+ // Doc references:
5
+ // https://developer.sailpoint.com/docs/extensibility/transforms/operations/
6
+ // JSON schemas: ../JSONS/sailpoint.isc.transforms.<type>.schema.json (all have additionalProperties:false)
7
+ import { getTransformSpec, toCanonicalType } from "./catalog.js";
8
+ import { deepNormalizeTransform } from "./normalize.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Per-type allowed attribute sets (derived from JSON schemas, additionalProperties:false)
11
+ // "open" = schema permits additionalProperties (dynamic variables etc.)
12
+ // ---------------------------------------------------------------------------
13
+ const ALLOWED_ATTRS = {
14
+ accountAttribute: new Set([
15
+ "sourceName", "applicationId", "applicationName", "attributeName",
16
+ "accountSortAttribute", "accountSortDescending", "accountReturnFirstLink",
17
+ "accountFilter", "accountPropertyFilter", "input",
18
+ ]),
19
+ base64Decode: new Set(["input"]),
20
+ base64Encode: new Set(["input"]),
21
+ concat: new Set(["values", "input"]),
22
+ conditional: "open", // dynamic variable keys allowed per docs
23
+ dateCompare: new Set(["firstDate", "secondDate", "operator", "positiveCondition", "negativeCondition", "input"]),
24
+ dateFormat: new Set(["input", "inputFormat", "outputFormat"]),
25
+ dateMath: new Set(["expression", "input", "roundUp"]),
26
+ decomposeDiacriticalMarks: new Set(["input"]),
27
+ displayName: new Set(["input"]),
28
+ e164phone: new Set(["input", "defaultRegion"]),
29
+ firstValid: new Set(["values", "input"]),
30
+ identityAttribute: new Set(["name", "input"]),
31
+ indexOf: new Set(["substring", "input"]),
32
+ iso3166: new Set(["format", "input"]),
33
+ join: new Set(["values", "separator"]),
34
+ lastIndexOf: new Set(["substring", "input"]),
35
+ leftPad: new Set(["length", "padding", "input"]),
36
+ lookup: new Set(["table", "input"]),
37
+ lower: new Set(["input"]),
38
+ normalizeNames: new Set(["input"]),
39
+ randomAlphaNumeric: new Set(["length"]),
40
+ randomNumeric: new Set(["length"]),
41
+ reference: new Set(["id"]),
42
+ replace: new Set(["regex", "replacement", "input"]),
43
+ replaceAll: new Set(["table", "input"]),
44
+ rfc5646: new Set(["format", "input"]),
45
+ rightPad: new Set(["length", "padding", "input"]),
46
+ rule: "open", // rule-specific attributes vary
47
+ split: new Set(["delimiter", "index", "input"]),
48
+ static: new Set(["value"]),
49
+ substring: new Set(["begin", "end", "input"]),
50
+ trim: new Set(["input"]),
51
+ upper: new Set(["input"]),
52
+ usernameGenerator: new Set(["patterns", "sourceCheck"]),
53
+ uuid: new Set([]),
54
+ // Rule-backed ops (normalized to type=rule, but linted by operation key)
55
+ generateRandomString: new Set(["name", "operation", "length", "includeNumbers", "includeSpecialChars"]),
56
+ getEndOfString: new Set(["name", "operation", "numChars", "input"]),
57
+ getReferenceIdentityAttribute: new Set(["name", "operation", "uid", "attributeName"]),
58
+ };
59
+ // Top-level fields allowed on every transform (per ISC Transform API schema)
60
+ const ALLOWED_TOP_LEVEL = new Set(["type", "name", "attributes", "internal", "requiresPeriodicRefresh", "id"]);
61
+ function push(msgs, level, message, path) {
62
+ msgs.push({ level, message, path });
63
+ }
64
+ function hasAny(obj, keys) {
65
+ return keys.some((k) => obj?.[k] !== undefined && obj?.[k] !== null && String(obj[k]).length > 0);
66
+ }
67
+ function isPlainObject(v) {
68
+ return v !== null && typeof v === "object" && !Array.isArray(v);
69
+ }
70
+ function isNumberish(v) {
71
+ if (typeof v === "number")
72
+ return Number.isFinite(v);
73
+ if (typeof v === "string" && v.trim().length)
74
+ return Number.isFinite(Number(v));
75
+ return false;
76
+ }
77
+ function isBooleanString(v) {
78
+ const s = String(v).toLowerCase();
79
+ return s === "true" || s === "false";
80
+ }
81
+ function looksLikeTransform(v) {
82
+ if (!isPlainObject(v))
83
+ return false;
84
+ if (typeof v.type !== "string" || v.type.length === 0)
85
+ return false;
86
+ if (v.attributes !== undefined && !isPlainObject(v.attributes))
87
+ return false;
88
+ return true;
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // 1. Top-level field validation
92
+ // ---------------------------------------------------------------------------
93
+ function lintTopLevel(input) {
94
+ const msgs = [];
95
+ // Unknown top-level keys → error (additionalProperties:false at root)
96
+ for (const k of Object.keys(input)) {
97
+ if (!ALLOWED_TOP_LEVEL.has(k)) {
98
+ push(msgs, "error", `Unknown top-level field '${k}'. Allowed: ${Array.from(ALLOWED_TOP_LEVEL).join(", ")}.`, k);
99
+ }
100
+ }
101
+ // name — required string, non-empty
102
+ if (input.name !== undefined) {
103
+ if (typeof input.name !== "string" || input.name.trim().length === 0) {
104
+ push(msgs, "error", "name must be a non-empty string.", "name");
105
+ }
106
+ }
107
+ // internal — boolean only
108
+ if (input.internal !== undefined && typeof input.internal !== "boolean") {
109
+ push(msgs, "error", "internal must be a boolean (true/false).", "internal");
110
+ }
111
+ // requiresPeriodicRefresh — boolean only (controls nightly identity refresh)
112
+ // Docs: https://developer.sailpoint.com/docs/extensibility/transforms/
113
+ if (input.requiresPeriodicRefresh !== undefined && typeof input.requiresPeriodicRefresh !== "boolean") {
114
+ push(msgs, "error", "requiresPeriodicRefresh must be a boolean (true/false). Default: false.", "requiresPeriodicRefresh");
115
+ }
116
+ return msgs;
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // 2. Required attribute checks (catalog-driven, with OR support)
120
+ // ---------------------------------------------------------------------------
121
+ function checkRequired(spec, attrs) {
122
+ const msgs = [];
123
+ for (const req of spec.requiredAttributes ?? []) {
124
+ if (req.includes("|")) {
125
+ const opts = req.split("|").map((s) => s.trim());
126
+ if (!hasAny(attrs, opts)) {
127
+ push(msgs, "error", `Missing required attribute — one of: ${opts.join(", ")}.`, "attributes");
128
+ }
129
+ continue;
130
+ }
131
+ const val = attrs?.[req];
132
+ if (val === undefined || val === null || (typeof val === "string" && val.length === 0)) {
133
+ push(msgs, "error", `Missing required attribute: ${req}.`, `attributes.${req}`);
134
+ }
135
+ }
136
+ return msgs;
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // 3. Unknown attribute check (strict per JSON schemas)
140
+ // ---------------------------------------------------------------------------
141
+ function lintUnknownAttributes(type, attrs) {
142
+ const msgs = [];
143
+ const allowed = ALLOWED_ATTRS[type];
144
+ if (!allowed || allowed === "open")
145
+ return msgs; // dynamic/open schemas
146
+ for (const k of Object.keys(attrs ?? {})) {
147
+ if (!allowed.has(k)) {
148
+ push(msgs, "error", `Unknown attribute '${k}' for transform type '${type}'. ` +
149
+ `Allowed attributes: ${Array.from(allowed).join(", ")}. ` +
150
+ `Remove or correct this field.`, `attributes.${k}`);
151
+ }
152
+ }
153
+ return msgs;
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // 4. Rule-backed invariants
157
+ // ---------------------------------------------------------------------------
158
+ function lintRuleBackedInvariants(requestedType, normalized, msgs) {
159
+ const ruleBackedOps = new Set(["generateRandomString", "getEndOfString", "getReferenceIdentityAttribute"]);
160
+ if (!ruleBackedOps.has(requestedType))
161
+ return;
162
+ const attrs = normalized?.attributes ?? {};
163
+ if (normalized?.type !== "rule") {
164
+ push(msgs, "error", `Rule-backed operation '${requestedType}' must be sent as type='rule' after normalization.`, "type");
165
+ }
166
+ if (String(attrs?.operation ?? "") !== requestedType) {
167
+ push(msgs, "error", `Rule-backed operation '${requestedType}' must set attributes.operation='${requestedType}'.`, "attributes.operation");
168
+ }
169
+ const expectedRuleName = "Cloud Services Deployment Utility";
170
+ if (String(attrs?.name ?? "") !== expectedRuleName) {
171
+ push(msgs, "error", `Rule-backed operation '${requestedType}' must set attributes.name='${expectedRuleName}'.`, "attributes.name");
172
+ }
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // 5. accountAttribute — source uniqueness (exactly ONE of sourceName / applicationId / applicationName)
176
+ // ---------------------------------------------------------------------------
177
+ function lintAccountAttribute(attrs) {
178
+ const msgs = [];
179
+ const sourceFields = ["sourceName", "applicationId", "applicationName"];
180
+ const presentSources = sourceFields.filter((f) => attrs?.[f] !== undefined && attrs?.[f] !== null && String(attrs[f]).trim() !== "");
181
+ if (presentSources.length === 0) {
182
+ push(msgs, "error", "accountAttribute requires exactly one source reference: sourceName, applicationId, or applicationName.", "attributes");
183
+ }
184
+ else if (presentSources.length > 1) {
185
+ push(msgs, "error", `accountAttribute must have exactly ONE source reference; found multiple: ${presentSources.join(", ")}. Remove all but one.`, "attributes");
186
+ }
187
+ // Type checks for optional boolean attrs
188
+ if (attrs?.accountSortDescending !== undefined && typeof attrs.accountSortDescending !== "boolean") {
189
+ push(msgs, "error", "accountSortDescending must be a boolean.", "attributes.accountSortDescending");
190
+ }
191
+ if (attrs?.accountReturnFirstLink !== undefined && typeof attrs.accountReturnFirstLink !== "boolean") {
192
+ push(msgs, "error", "accountReturnFirstLink must be a boolean.", "attributes.accountReturnFirstLink");
193
+ }
194
+ if (attrs?.accountSortAttribute !== undefined && typeof attrs.accountSortAttribute !== "string") {
195
+ push(msgs, "error", "accountSortAttribute must be a string.", "attributes.accountSortAttribute");
196
+ }
197
+ if (attrs?.accountFilter !== undefined && typeof attrs.accountFilter !== "string") {
198
+ push(msgs, "error", "accountFilter must be a string.", "attributes.accountFilter");
199
+ }
200
+ if (attrs?.accountPropertyFilter !== undefined && typeof attrs.accountPropertyFilter !== "string") {
201
+ push(msgs, "error", "accountPropertyFilter must be a string.", "attributes.accountPropertyFilter");
202
+ }
203
+ return msgs;
204
+ }
205
+ // ---------------------------------------------------------------------------
206
+ // 6. conditional — expression format
207
+ // ---------------------------------------------------------------------------
208
+ function lintConditional(attrs) {
209
+ const msgs = [];
210
+ const exprRaw = attrs?.expression;
211
+ const expr = String(exprRaw ?? "").trim();
212
+ if (!expr) {
213
+ push(msgs, "error", "Missing required attribute: expression.", "attributes.expression");
214
+ return msgs;
215
+ }
216
+ const forbidden = /(!=|==|>=|<=|>|<|\bne\b|\bgt\b|\blt\b|\bge\b|\ble\b)/i;
217
+ if (forbidden.test(expr)) {
218
+ push(msgs, "error", `Unsupported operator in expression: '${expr}'. Only 'eq' comparator is supported (e.g., '$var eq value').`, "attributes.expression");
219
+ }
220
+ if (!/\beq\b/i.test(expr)) {
221
+ push(msgs, "error", `Conditional expression must use 'eq' comparator only. Got: '${expr}'.`, "attributes.expression");
222
+ }
223
+ const parts = expr.split(/\beq\b/i);
224
+ if (parts.length !== 2 || parts[0].trim().length === 0 || parts[1].trim().length === 0) {
225
+ push(msgs, "error", `Conditional expression must follow '<ValueA> eq <ValueB>' format. Got: '${expr}'.`, "attributes.expression");
226
+ }
227
+ if (typeof attrs?.positiveCondition !== "string") {
228
+ push(msgs, "error", "positiveCondition must be a string.", "attributes.positiveCondition");
229
+ }
230
+ if (typeof attrs?.negativeCondition !== "string") {
231
+ push(msgs, "error", "negativeCondition must be a string.", "attributes.negativeCondition");
232
+ }
233
+ return msgs;
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ // 7. replace — validate regex compiles
237
+ // ---------------------------------------------------------------------------
238
+ function lintReplace(attrs) {
239
+ const msgs = [];
240
+ if (attrs?.regex !== undefined) {
241
+ if (typeof attrs.regex !== "string") {
242
+ push(msgs, "error", "regex must be a string.", "attributes.regex");
243
+ }
244
+ else {
245
+ // Try to compile the regex — catch syntax errors early before ISC does
246
+ try {
247
+ new RegExp(attrs.regex);
248
+ }
249
+ catch (e) {
250
+ push(msgs, "error", `regex '${attrs.regex}' is not a valid regular expression: ${e?.message ?? e}.`, "attributes.regex");
251
+ }
252
+ }
253
+ }
254
+ if (attrs?.replacement !== undefined && typeof attrs.replacement !== "string") {
255
+ push(msgs, "error", "replacement must be a string.", "attributes.replacement");
256
+ }
257
+ if (attrs?.input === undefined) {
258
+ push(msgs, "warn", "replace typically expects an attributes.input (nested transform or attribute reference).", "attributes.input");
259
+ }
260
+ return msgs;
261
+ }
262
+ // ---------------------------------------------------------------------------
263
+ // 8. replaceAll — table validation
264
+ // ---------------------------------------------------------------------------
265
+ function lintReplaceAll(attrs) {
266
+ const msgs = [];
267
+ if (attrs?.table !== undefined) {
268
+ if (!isPlainObject(attrs.table) || Array.isArray(attrs.table)) {
269
+ push(msgs, "error", "table must be an object map of key → value string pairs.", "attributes.table");
270
+ }
271
+ else {
272
+ const badEntries = Object.entries(attrs.table).filter(([, v]) => typeof v !== "string");
273
+ if (badEntries.length) {
274
+ push(msgs, "error", `All table values must be strings. Non-string entries: ${badEntries.map(([k]) => k).join(", ")}.`, "attributes.table");
275
+ }
276
+ }
277
+ }
278
+ return msgs;
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // 9. dateMath — expression grammar
282
+ // ---------------------------------------------------------------------------
283
+ function lintDateMath(attrs) {
284
+ const msgs = [];
285
+ const expr = attrs?.expression;
286
+ if (expr !== undefined) {
287
+ if (typeof expr !== "string" || expr.trim().length === 0) {
288
+ push(msgs, "error", "expression must be a non-empty string.", "attributes.expression");
289
+ }
290
+ else {
291
+ const s = expr.trim();
292
+ if (/\s/.test(s)) {
293
+ push(msgs, "error", "expression must not contain whitespace.", "attributes.expression");
294
+ }
295
+ if (!/^[0-9yMwdhmsnow+\-/]+$/.test(s)) {
296
+ push(msgs, "error", "expression contains invalid characters. Allowed: digits, y M w d h m s, now, +, -, /.", "attributes.expression");
297
+ }
298
+ let i = 0;
299
+ const n = s.length;
300
+ const startsWithNow = s.startsWith("now");
301
+ if (startsWithNow)
302
+ i += 3;
303
+ if (!startsWithNow && s.includes("now")) {
304
+ push(msgs, "error", "'now' keyword must appear only at the start of the expression.", "attributes.expression");
305
+ }
306
+ let sawOp = false, sawRound = false, roundUnit = null;
307
+ const readInt = () => {
308
+ const start = i;
309
+ while (i < n && /[0-9]/.test(s[i]))
310
+ i++;
311
+ return i > start ? s.slice(start, i) : null;
312
+ };
313
+ const readUnit = () => {
314
+ if (i >= n)
315
+ return null;
316
+ const ch = s[i];
317
+ if (!new Set(["y", "M", "w", "d", "h", "m", "s"]).has(ch))
318
+ return null;
319
+ i++;
320
+ return ch;
321
+ };
322
+ const readSignedTerm = (op) => {
323
+ const num = readInt();
324
+ if (!num) {
325
+ push(msgs, "error", `Missing integer after '${op}' in expression.`, "attributes.expression");
326
+ return;
327
+ }
328
+ if (Number(num) === 0)
329
+ push(msgs, "warn", `Term '${op}${num}' is a no-op (zero).`, "attributes.expression");
330
+ const unit = readUnit();
331
+ if (!unit)
332
+ push(msgs, "error", `Missing unit after '${op}${num}'. Allowed: y M w d h m s.`, "attributes.expression");
333
+ };
334
+ const readRound = () => {
335
+ const unit = readUnit();
336
+ if (!unit) {
337
+ push(msgs, "error", "Rounding '/' must be followed by a unit (y M w d h m s).", "attributes.expression");
338
+ return;
339
+ }
340
+ sawRound = true;
341
+ roundUnit = unit;
342
+ };
343
+ if (i < n && s[i] === "/") {
344
+ i++;
345
+ readRound();
346
+ if (i < n)
347
+ push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
348
+ }
349
+ else {
350
+ while (i < n) {
351
+ const ch = s[i];
352
+ if (ch === "+" || ch === "-") {
353
+ if (sawRound) {
354
+ push(msgs, "error", "Add/subtract terms cannot appear after rounding ('/').", "attributes.expression");
355
+ break;
356
+ }
357
+ sawOp = true;
358
+ i++;
359
+ readSignedTerm(ch);
360
+ continue;
361
+ }
362
+ if (ch === "/") {
363
+ if (sawRound) {
364
+ push(msgs, "error", "Only one rounding operator '/' is supported.", "attributes.expression");
365
+ break;
366
+ }
367
+ i++;
368
+ readRound();
369
+ if (i < n)
370
+ push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
371
+ break;
372
+ }
373
+ push(msgs, "error", `Unexpected token '${ch}'. Use patterns like 'now-5d/d' or '+3M/h'.`, "attributes.expression");
374
+ break;
375
+ }
376
+ }
377
+ if (!startsWithNow && s !== "" && s[0] !== "+" && s[0] !== "-" && s[0] !== "/") {
378
+ push(msgs, "error", "Expression must start with 'now', '+', '-', or '/'.", "attributes.expression");
379
+ }
380
+ // Week rounding not supported (SailPoint docs)
381
+ if (roundUnit === "w") {
382
+ push(msgs, "error", "Rounding with 'w' (week) is not supported in dateMath expressions.", "attributes.expression");
383
+ }
384
+ if (!startsWithNow && !sawOp && !sawRound) {
385
+ push(msgs, "error", "Expression must contain 'now', at least one +/- term, or a rounding segment.", "attributes.expression");
386
+ }
387
+ if (!startsWithNow && attrs?.input === undefined) {
388
+ push(msgs, "error", "dateMath expression without 'now' requires an input date transform in attributes.input.", "attributes.input");
389
+ }
390
+ }
391
+ }
392
+ if (attrs?.roundUp !== undefined && typeof attrs.roundUp !== "boolean") {
393
+ push(msgs, "error", "roundUp must be a boolean.", "attributes.roundUp");
394
+ }
395
+ if (attrs?.input !== undefined) {
396
+ const inp = attrs.input;
397
+ if (!(inp && typeof inp === "object" && typeof inp.type === "string")) {
398
+ push(msgs, "warn", "input should be a nested transform object {type, attributes}.", "attributes.input");
399
+ }
400
+ }
401
+ return msgs;
402
+ }
403
+ // ---------------------------------------------------------------------------
404
+ // 10. dateCompare — field validation
405
+ // ---------------------------------------------------------------------------
406
+ function lintDateCompare(attrs) {
407
+ const msgs = [];
408
+ const ISO8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
409
+ const isNestedTransform = (v) => !!(v && typeof v === "object" && typeof v.type === "string");
410
+ const checkDateOperand = (field) => {
411
+ const v = attrs?.[field];
412
+ if (v === undefined || v === null) {
413
+ push(msgs, "error", `${field} is required.`, `attributes.${field}`);
414
+ return;
415
+ }
416
+ if (typeof v === "string") {
417
+ const t = v.trim();
418
+ if (t.toLowerCase() === "now")
419
+ return;
420
+ if (!ISO8601_RE.test(t)) {
421
+ push(msgs, "error", `${field} must be an ISO8601 datetime (e.g., 2025-01-01T00:00:00Z), 'now', or a nested transform.`, `attributes.${field}`);
422
+ }
423
+ return;
424
+ }
425
+ if (isNestedTransform(v))
426
+ return;
427
+ push(msgs, "error", `${field} must be a string (ISO8601/'now') or a nested transform object.`, `attributes.${field}`);
428
+ };
429
+ checkDateOperand("firstDate");
430
+ checkDateOperand("secondDate");
431
+ const op = attrs?.operator;
432
+ if (!op || String(op).trim() === "") {
433
+ push(msgs, "error", "operator is required.", "attributes.operator");
434
+ }
435
+ else {
436
+ const v = String(op).trim().toUpperCase();
437
+ if (!new Set(["LT", "LTE", "GT", "GTE"]).has(v)) {
438
+ push(msgs, "error", "operator must be one of: LT, LTE, GT, GTE (case-insensitive).", "attributes.operator");
439
+ }
440
+ }
441
+ if (attrs?.positiveCondition === undefined || attrs.positiveCondition === null) {
442
+ push(msgs, "error", "positiveCondition is required for dateCompare.", "attributes.positiveCondition");
443
+ }
444
+ else if (typeof attrs.positiveCondition !== "string") {
445
+ push(msgs, "error", "positiveCondition must be a string.", "attributes.positiveCondition");
446
+ }
447
+ if (attrs?.negativeCondition === undefined || attrs.negativeCondition === null) {
448
+ push(msgs, "error", "negativeCondition is required for dateCompare.", "attributes.negativeCondition");
449
+ }
450
+ else if (typeof attrs.negativeCondition !== "string") {
451
+ push(msgs, "error", "negativeCondition must be a string.", "attributes.negativeCondition");
452
+ }
453
+ return msgs;
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // 11. dateFormat — named formats + pattern validation
457
+ // ---------------------------------------------------------------------------
458
+ function lintDateFormat(attrs) {
459
+ const msgs = [];
460
+ const NAMED_FORMATS = new Set(["ISO8601", "EPOCH_TIME_JAVA", "EPOCH_TIME_WIN32", "LDAP_GENERALIZED_TIME"]);
461
+ const isLikelyPattern = (s) => /[yMdHhmsSZ]/.test(s);
462
+ const checkFmt = (field) => {
463
+ const raw = attrs?.[field];
464
+ if (raw === undefined)
465
+ return;
466
+ if (typeof raw !== "string") {
467
+ push(msgs, "error", `${field} must be a string (named format or pattern).`, `attributes.${field}`);
468
+ return;
469
+ }
470
+ const t = raw.trim();
471
+ const lower = t.toLowerCase();
472
+ if (lower === "epoch" || lower === "unix" || lower === "unixtime" || lower === "javaepoch") {
473
+ push(msgs, "error", `Unsupported ${field} '${t}'. Use a documented named format: ${Array.from(NAMED_FORMATS).join(", ")}.`, `attributes.${field}`);
474
+ return;
475
+ }
476
+ if (NAMED_FORMATS.has(t.toUpperCase()))
477
+ return;
478
+ if (!isLikelyPattern(t)) {
479
+ push(msgs, "warn", `${field} '${t}' doesn't match a known named format and doesn't look like a date pattern (missing date tokens like y/M/d/H).`, `attributes.${field}`);
480
+ }
481
+ };
482
+ checkFmt("inputFormat");
483
+ checkFmt("outputFormat");
484
+ if (attrs?.input !== undefined) {
485
+ const inp = attrs.input;
486
+ if (typeof inp === "object" && inp !== null && typeof inp.type !== "string") {
487
+ push(msgs, "warn", "input looks like an object but is missing a nested transform 'type'.", "attributes.input");
488
+ }
489
+ else if (typeof inp !== "string" && !isPlainObject(inp)) {
490
+ push(msgs, "warn", "input should be a string or a nested transform object.", "attributes.input");
491
+ }
492
+ }
493
+ return msgs;
494
+ }
495
+ // ---------------------------------------------------------------------------
496
+ // 12. usernameGenerator — patterns array
497
+ // ---------------------------------------------------------------------------
498
+ function lintUsernameGenerator(attrs) {
499
+ const msgs = [];
500
+ const patterns = attrs?.patterns;
501
+ if (patterns !== undefined) {
502
+ if (!Array.isArray(patterns) || patterns.length === 0) {
503
+ push(msgs, "error", "patterns must be a non-empty array of strings.", "attributes.patterns");
504
+ }
505
+ else {
506
+ const bad = patterns.findIndex((p) => typeof p !== "string" || p.trim().length === 0);
507
+ if (bad >= 0) {
508
+ push(msgs, "error", `Each pattern must be a non-empty string. Invalid at index [${bad}].`, `attributes.patterns[${bad}]`);
509
+ }
510
+ // uniqueCounter must be last per docs
511
+ const idx = patterns.findIndex((p) => typeof p === "string" && p.includes("uniqueCounter"));
512
+ if (idx >= 0 && idx !== patterns.length - 1) {
513
+ push(msgs, "warn", "Pattern containing 'uniqueCounter' should be last in the patterns array.", "attributes.patterns");
514
+ }
515
+ }
516
+ }
517
+ if (attrs?.sourceCheck !== undefined && typeof attrs.sourceCheck !== "boolean") {
518
+ push(msgs, "error", "sourceCheck must be a boolean.", "attributes.sourceCheck");
519
+ }
520
+ return msgs;
521
+ }
522
+ // ---------------------------------------------------------------------------
523
+ // 13. generateRandomString — length + bool string attrs
524
+ // ---------------------------------------------------------------------------
525
+ function lintGenerateRandomString(attrs) {
526
+ const msgs = [];
527
+ const length = attrs?.length;
528
+ if (length !== undefined) {
529
+ const n = Number(length);
530
+ if (!Number.isFinite(n) || n <= 0) {
531
+ push(msgs, "error", "length must be a positive number (string or number).", "attributes.length");
532
+ }
533
+ else if (n > 450) {
534
+ push(msgs, "warn", "length exceeds documented maximum (450).", "attributes.length");
535
+ }
536
+ }
537
+ for (const k of ["includeNumbers", "includeSpecialChars"]) {
538
+ if (attrs?.[k] !== undefined && !isBooleanString(attrs[k])) {
539
+ push(msgs, "error", `${k} must be "true" or "false" (string), as required by the CSDU rule.`, `attributes.${k}`);
540
+ }
541
+ }
542
+ return msgs;
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // 14. getEndOfString — numChars (not length)
546
+ // ---------------------------------------------------------------------------
547
+ function lintGetEndOfString(attrs) {
548
+ const msgs = [];
549
+ if (attrs?.length !== undefined && attrs?.numChars === undefined) {
550
+ push(msgs, "error", "getEndOfString uses 'numChars', not 'length'. Rename the attribute. Docs: https://developer.sailpoint.com/docs/extensibility/transforms/operations/get-end-of-string/", "attributes.length");
551
+ }
552
+ const numChars = attrs?.numChars;
553
+ if (numChars !== undefined) {
554
+ const n = Number(numChars);
555
+ if (!Number.isFinite(n) || n <= 0) {
556
+ push(msgs, "error", "numChars must be a positive number.", "attributes.numChars");
557
+ }
558
+ }
559
+ return msgs;
560
+ }
561
+ // ---------------------------------------------------------------------------
562
+ // 15. getReferenceIdentityAttribute
563
+ // ---------------------------------------------------------------------------
564
+ function lintGetReferenceIdentityAttribute(attrs) {
565
+ const msgs = [];
566
+ if (attrs?.uid !== undefined && typeof attrs.uid !== "string") {
567
+ push(msgs, "error", "uid must be a string (identity username or 'manager' keyword).", "attributes.uid");
568
+ }
569
+ if (attrs?.attributeName !== undefined && typeof attrs.attributeName !== "string") {
570
+ push(msgs, "error", "attributeName must be a string (identity attribute system name).", "attributes.attributeName");
571
+ }
572
+ return msgs;
573
+ }
574
+ // ---------------------------------------------------------------------------
575
+ // 16. join — separator (not delimiter)
576
+ // ---------------------------------------------------------------------------
577
+ function lintJoin(attrs) {
578
+ const msgs = [];
579
+ if (attrs?.delimiter !== undefined) {
580
+ push(msgs, "error", "'delimiter' is not a valid attribute for join. Use 'separator' instead. Docs: https://developer.sailpoint.com/docs/extensibility/transforms/operations/join/", "attributes.delimiter");
581
+ }
582
+ if (attrs?.separator !== undefined && typeof attrs.separator !== "string") {
583
+ push(msgs, "error", "separator must be a string.", "attributes.separator");
584
+ }
585
+ if (attrs?.values !== undefined && !Array.isArray(attrs.values)) {
586
+ push(msgs, "error", "values must be an array.", "attributes.values");
587
+ }
588
+ return msgs;
589
+ }
590
+ // ---------------------------------------------------------------------------
591
+ // 17. iso3166 — format enum
592
+ // ---------------------------------------------------------------------------
593
+ function lintIso3166(attrs) {
594
+ const msgs = [];
595
+ if (attrs?.format !== undefined) {
596
+ if (typeof attrs.format !== "string") {
597
+ push(msgs, "error", "format must be a string.", "attributes.format");
598
+ }
599
+ else if (!new Set(["alpha2", "alpha3", "numeric"]).has(attrs.format)) {
600
+ push(msgs, "error", "format must be one of: alpha2, alpha3, numeric.", "attributes.format");
601
+ }
602
+ }
603
+ if (attrs?.defaultRegion !== undefined) {
604
+ push(msgs, "error", "'defaultRegion' is not a valid attribute for iso3166. Did you mean the e164phone transform?", "attributes.defaultRegion");
605
+ }
606
+ return msgs;
607
+ }
608
+ // ---------------------------------------------------------------------------
609
+ // 18. lookup — table validation + default key warning
610
+ // ---------------------------------------------------------------------------
611
+ function lintLookup(attrs) {
612
+ const msgs = [];
613
+ if (attrs?.table !== undefined) {
614
+ if (!isPlainObject(attrs.table)) {
615
+ push(msgs, "error", "table must be an object map of key → string value.", "attributes.table");
616
+ }
617
+ else {
618
+ if (!Object.prototype.hasOwnProperty.call(attrs.table, "default")) {
619
+ push(msgs, "warn", "lookup table is missing a 'default' key. Without it, the transform errors if input doesn't match any key.", "attributes.table");
620
+ }
621
+ const badVals = Object.entries(attrs.table).filter(([, v]) => typeof v !== "string");
622
+ if (badVals.length) {
623
+ push(msgs, "error", `All lookup table values must be strings. Non-string entries: ${badVals.map(([k]) => k).join(", ")}.`, "attributes.table");
624
+ }
625
+ }
626
+ }
627
+ return msgs;
628
+ }
629
+ // ---------------------------------------------------------------------------
630
+ // 19. e164phone — defaultRegion format
631
+ // ---------------------------------------------------------------------------
632
+ function lintE164Phone(attrs) {
633
+ const msgs = [];
634
+ if (attrs?.defaultRegion !== undefined) {
635
+ if (typeof attrs.defaultRegion !== "string") {
636
+ push(msgs, "error", "defaultRegion must be an ISO 3166-1 alpha-2 region code string (e.g., 'US', 'AU').", "attributes.defaultRegion");
637
+ }
638
+ else if (!/^[A-Z]{2}$/.test(attrs.defaultRegion.toUpperCase())) {
639
+ push(msgs, "warn", `defaultRegion '${attrs.defaultRegion}' doesn't look like a 2-letter ISO 3166-1 alpha-2 code.`, "attributes.defaultRegion");
640
+ }
641
+ }
642
+ return msgs;
643
+ }
644
+ // ---------------------------------------------------------------------------
645
+ // 20. normalizeNames — unsupported attributes
646
+ // ---------------------------------------------------------------------------
647
+ function lintNormalizeNames(attrs) {
648
+ const msgs = [];
649
+ if (attrs?.regex !== undefined) {
650
+ push(msgs, "error", "'regex' is not a documented attribute for normalizeNames. This transform uses built-in normalization rules.", "attributes.regex");
651
+ }
652
+ if (attrs?.replacement !== undefined) {
653
+ push(msgs, "error", "'replacement' is not a documented attribute for normalizeNames. This transform uses built-in normalization rules.", "attributes.replacement");
654
+ }
655
+ return msgs;
656
+ }
657
+ // ---------------------------------------------------------------------------
658
+ // 21. split — delimiter + index types
659
+ // ---------------------------------------------------------------------------
660
+ function lintSplit(attrs) {
661
+ const msgs = [];
662
+ if (attrs?.delimiter !== undefined && typeof attrs.delimiter !== "string") {
663
+ push(msgs, "error", "delimiter must be a string.", "attributes.delimiter");
664
+ }
665
+ if (attrs?.index !== undefined && !isNumberish(attrs.index)) {
666
+ push(msgs, "error", "index must be a number (or numeric string).", "attributes.index");
667
+ }
668
+ if (attrs?.input === undefined) {
669
+ push(msgs, "warn", "split typically expects attributes.input.", "attributes.input");
670
+ }
671
+ return msgs;
672
+ }
673
+ // ---------------------------------------------------------------------------
674
+ // 22. pad transforms (leftPad / rightPad)
675
+ // ---------------------------------------------------------------------------
676
+ function lintPad(attrs) {
677
+ const msgs = [];
678
+ if (attrs?.length !== undefined && !isNumberish(attrs.length)) {
679
+ push(msgs, "error", "length must be a number (or numeric string).", "attributes.length");
680
+ }
681
+ if (attrs?.padding !== undefined && typeof attrs.padding !== "string") {
682
+ push(msgs, "error", "padding must be a string.", "attributes.padding");
683
+ }
684
+ if (attrs?.input === undefined) {
685
+ push(msgs, "warn", "pad transforms typically expect attributes.input.", "attributes.input");
686
+ }
687
+ return msgs;
688
+ }
689
+ // ---------------------------------------------------------------------------
690
+ // 23. substring — begin/end numbers
691
+ // ---------------------------------------------------------------------------
692
+ function lintSubstring(attrs) {
693
+ const msgs = [];
694
+ if (attrs?.begin !== undefined && !isNumberish(attrs.begin)) {
695
+ push(msgs, "error", "begin must be a number (or numeric string).", "attributes.begin");
696
+ }
697
+ if (attrs?.end !== undefined && !isNumberish(attrs.end)) {
698
+ push(msgs, "error", "end must be a number (or numeric string).", "attributes.end");
699
+ }
700
+ return msgs;
701
+ }
702
+ // ---------------------------------------------------------------------------
703
+ // 24. static — value must be a string
704
+ // ---------------------------------------------------------------------------
705
+ function lintStatic(attrs) {
706
+ const msgs = [];
707
+ if (attrs?.value !== undefined && typeof attrs.value !== "string") {
708
+ push(msgs, "error", "value must be a string.", "attributes.value");
709
+ }
710
+ return msgs;
711
+ }
712
+ // ---------------------------------------------------------------------------
713
+ // 25. indexOf / lastIndexOf
714
+ // ---------------------------------------------------------------------------
715
+ function lintIndexOf(t, attrs) {
716
+ const msgs = [];
717
+ if (attrs?.substring !== undefined && typeof attrs.substring !== "string") {
718
+ push(msgs, "error", "substring must be a string.", "attributes.substring");
719
+ }
720
+ if (attrs?.input === undefined) {
721
+ push(msgs, "warn", `${t} typically expects attributes.input.`, "attributes.input");
722
+ }
723
+ return msgs;
724
+ }
725
+ // ---------------------------------------------------------------------------
726
+ // 26. randomAlphaNumeric / randomNumeric — length
727
+ // ---------------------------------------------------------------------------
728
+ function lintRandom(t, attrs) {
729
+ const msgs = [];
730
+ if (attrs?.length !== undefined) {
731
+ const n = Number(attrs.length);
732
+ if (!Number.isFinite(n) || n <= 0) {
733
+ push(msgs, "error", "length must be a positive number.", "attributes.length");
734
+ }
735
+ else if (n > 450) {
736
+ push(msgs, "warn", "length exceeds documented maximum (450).", "attributes.length");
737
+ }
738
+ }
739
+ return msgs;
740
+ }
741
+ // ---------------------------------------------------------------------------
742
+ // 27. rfc5646 — format type check
743
+ // ---------------------------------------------------------------------------
744
+ function lintRfc5646(attrs) {
745
+ const msgs = [];
746
+ if (attrs?.format !== undefined && typeof attrs.format !== "string") {
747
+ push(msgs, "error", "format must be a string.", "attributes.format");
748
+ }
749
+ return msgs;
750
+ }
751
+ // ---------------------------------------------------------------------------
752
+ // Main lintTransform export
753
+ // ---------------------------------------------------------------------------
754
+ export function lintTransform(input) {
755
+ const messages = [];
756
+ if (!input || typeof input !== "object") {
757
+ return { normalized: input, messages: [{ level: "error", message: "Transform must be a JSON object." }] };
758
+ }
759
+ // --- Top-level field validation ---
760
+ messages.push(...lintTopLevel(input));
761
+ const requestedType = toCanonicalType(String(input.type || ""));
762
+ if (!requestedType) {
763
+ return {
764
+ normalized: input,
765
+ messages: [
766
+ ...messages,
767
+ { level: "error", message: `Unknown transform type: '${String(input.type)}'. Run isc.transforms.catalog to see all valid types.` },
768
+ ],
769
+ };
770
+ }
771
+ const spec = getTransformSpec(requestedType);
772
+ if (!spec) {
773
+ return {
774
+ normalized: input,
775
+ messages: [...messages, { level: "error", message: `No spec found for transform type: ${requestedType}` }],
776
+ };
777
+ }
778
+ // --- Normalize ---
779
+ const normalized = deepNormalizeTransform(input);
780
+ const attrs = normalized.attributes ?? {};
781
+ // --- attributes object required check ---
782
+ if (!spec.attributesOptional && (!normalized.attributes || typeof normalized.attributes !== "object")) {
783
+ push(messages, "error", "attributes is required and must be an object.", "attributes");
784
+ }
785
+ // --- Required attribute checks ---
786
+ messages.push(...checkRequired(spec, attrs));
787
+ // --- Unknown attribute check (strict, per JSON schemas) ---
788
+ messages.push(...lintUnknownAttributes(requestedType, attrs));
789
+ // --- Rule-backed invariants ---
790
+ lintRuleBackedInvariants(requestedType, normalized, messages);
791
+ // --- Operation-specific lint ---
792
+ if (requestedType === "accountAttribute")
793
+ messages.push(...lintAccountAttribute(attrs));
794
+ if (requestedType === "conditional")
795
+ messages.push(...lintConditional(attrs));
796
+ if (requestedType === "replace")
797
+ messages.push(...lintReplace(attrs));
798
+ if (requestedType === "replaceAll")
799
+ messages.push(...lintReplaceAll(attrs));
800
+ if (requestedType === "dateMath")
801
+ messages.push(...lintDateMath(attrs));
802
+ if (requestedType === "dateCompare")
803
+ messages.push(...lintDateCompare(attrs));
804
+ if (requestedType === "dateFormat")
805
+ messages.push(...lintDateFormat(attrs));
806
+ if (requestedType === "usernameGenerator")
807
+ messages.push(...lintUsernameGenerator(attrs));
808
+ if (requestedType === "generateRandomString")
809
+ messages.push(...lintGenerateRandomString(attrs));
810
+ if (requestedType === "getEndOfString")
811
+ messages.push(...lintGetEndOfString(attrs));
812
+ if (requestedType === "getReferenceIdentityAttribute")
813
+ messages.push(...lintGetReferenceIdentityAttribute(attrs));
814
+ if (requestedType === "join")
815
+ messages.push(...lintJoin(attrs));
816
+ if (requestedType === "iso3166")
817
+ messages.push(...lintIso3166(attrs));
818
+ if (requestedType === "lookup")
819
+ messages.push(...lintLookup(attrs));
820
+ if (requestedType === "e164phone")
821
+ messages.push(...lintE164Phone(attrs));
822
+ if (requestedType === "normalizeNames")
823
+ messages.push(...lintNormalizeNames(attrs));
824
+ if (requestedType === "split")
825
+ messages.push(...lintSplit(attrs));
826
+ if (requestedType === "leftPad" || requestedType === "rightPad")
827
+ messages.push(...lintPad(attrs));
828
+ if (requestedType === "substring")
829
+ messages.push(...lintSubstring(attrs));
830
+ if (requestedType === "static")
831
+ messages.push(...lintStatic(attrs));
832
+ if (requestedType === "indexOf" || requestedType === "lastIndexOf")
833
+ messages.push(...lintIndexOf(requestedType, attrs));
834
+ if (requestedType === "randomAlphaNumeric" || requestedType === "randomNumeric")
835
+ messages.push(...lintRandom(requestedType, attrs));
836
+ if (requestedType === "rfc5646")
837
+ messages.push(...lintRfc5646(attrs));
838
+ return { normalized, messages };
839
+ }