isc-transforms-mcp 1.0.10 → 1.0.12

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "sailpoint.isc.transforms.dateFormat.schema.json",
4
4
  "title": "SailPoint ISC Transform Schema - dateFormat",
5
- "description": "Strict schema derived from SailPoint official Date Format operation documentation. Converts datetime strings from one format to another. inputFormat/outputFormat may be Java SimpleDateFormat patterns or built-in named formats (ISO8601, LDAP, PEOPLE_SOFT, EPOCH_TIME_JAVA, EPOCH_TIME_WIN32). Note: The docs state this transform does not support the 'now' keyword as an input value.",
5
+ "description": "Strict schema for the SailPoint ISC Date Format transform. Converts a datetime string from one format to another using Java SimpleDateFormat patterns or 5 named formats: ISO8601, LDAP, PEOPLE_SOFT, EPOCH_TIME_JAVA, EPOCH_TIME_WIN32. IMPORTANT: The 'now' keyword is NOT supported as an input value for this transform.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -11,55 +11,61 @@
11
11
  ],
12
12
  "properties": {
13
13
  "type": {
14
- "const": "dateFormat"
14
+ "const": "dateFormat",
15
+ "description": "Transform operation type. Must be exactly 'dateFormat'."
15
16
  },
16
17
  "name": {
17
18
  "type": "string",
18
- "minLength": 1
19
+ "minLength": 1,
20
+ "description": "Display name for this transform, shown in UI dropdowns and identity profile mappings."
19
21
  },
20
22
  "requiresPeriodicRefresh": {
21
23
  "type": "boolean",
22
24
  "default": false,
23
- "description": "Whether the transform logic should be reevaluated nightly as part of identity refresh. Default false."
25
+ "description": "If true, re-evaluates this transform during the nightly identity refresh cycle. Default is false."
24
26
  },
25
27
  "attributes": {
26
28
  "type": "object",
27
- "description": "Date format configuration (optional). If omitted, inputFormat and outputFormat default to ISO8601.",
29
+ "description": "Date format configuration. All attributes are optional inputFormat and outputFormat both default to ISO8601 if omitted.",
28
30
  "additionalProperties": false,
29
31
  "properties": {
30
32
  "inputFormat": {
31
33
  "default": "ISO8601",
32
- "description": "Format of the incoming date. Must be one of the 5 named formats or a Java SimpleDateFormat pattern.",
34
+ "description": "Format of the incoming date string. Default is ISO8601. Accepts one of the 5 named formats OR any Java SimpleDateFormat pattern. Named formats: ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ), LDAP (yyyyMMddHHmmss.Z), PEOPLE_SOFT (MM/dd/yyyy), EPOCH_TIME_JAVA (milliseconds since Jan 1 1970), EPOCH_TIME_WIN32 (100-nanosecond intervals since Jan 1 1601).",
33
35
  "anyOf": [
34
36
  {
35
37
  "enum": ["ISO8601", "LDAP", "PEOPLE_SOFT", "EPOCH_TIME_JAVA", "EPOCH_TIME_WIN32"],
36
- "description": "Built-in named format."
38
+ "description": "Built-in named format. ISO8601=yyyy-MM-dd'T'HH:mm:ss.SSSZ, LDAP=yyyyMMddHHmmss.Z, PEOPLE_SOFT=MM/dd/yyyy, EPOCH_TIME_JAVA=ms since 1970, EPOCH_TIME_WIN32=100ns intervals since 1601."
37
39
  },
38
40
  {
39
41
  "type": "string",
40
42
  "not": { "pattern": "^[A-Z][A-Z0-9_]+$" },
41
- "description": "Java SimpleDateFormat pattern (e.g. dd-MM-yyyy, yyyy-MM-dd'T'HH:mm:ssZ)."
43
+ "description": "Java SimpleDateFormat pattern (e.g. 'M/d/yyyy', 'dd-MM-yyyy', \"yyyy-MM-dd'T'HH:mm:ssZ\"). Must contain at least one date token."
42
44
  }
43
45
  ]
44
46
  },
45
47
  "outputFormat": {
46
48
  "default": "ISO8601",
47
- "description": "Desired output format. Must be one of the 5 named formats or a Java SimpleDateFormat pattern.",
49
+ "description": "Desired output format. Default is ISO8601. Accepts one of the 5 named formats OR any Java SimpleDateFormat pattern. Named formats: ISO8601 (yyyy-MM-dd'T'HH:mm:ss.SSSZ), LDAP (yyyyMMddHHmmss.Z), PEOPLE_SOFT (MM/dd/yyyy), EPOCH_TIME_JAVA (milliseconds since Jan 1 1970), EPOCH_TIME_WIN32 (100-nanosecond intervals since Jan 1 1601).",
48
50
  "anyOf": [
49
51
  {
50
52
  "enum": ["ISO8601", "LDAP", "PEOPLE_SOFT", "EPOCH_TIME_JAVA", "EPOCH_TIME_WIN32"],
51
- "description": "Built-in named format."
53
+ "description": "Built-in named format. ISO8601=yyyy-MM-dd'T'HH:mm:ss.SSSZ, LDAP=yyyyMMddHHmmss.Z, PEOPLE_SOFT=MM/dd/yyyy, EPOCH_TIME_JAVA=ms since 1970, EPOCH_TIME_WIN32=100ns intervals since 1601."
52
54
  },
53
55
  {
54
56
  "type": "string",
55
57
  "not": { "pattern": "^[A-Z][A-Z0-9_]+$" },
56
- "description": "Java SimpleDateFormat pattern (e.g. dd-MM-yyyy, yyyy-MM-dd'T'HH:mm:ssZ)."
58
+ "description": "Java SimpleDateFormat pattern (e.g. 'yyyy-MM-dd', \"yyyy-MM-dd'T'HH:mm:ssZ\"). Must contain at least one date token."
57
59
  }
58
60
  ]
59
61
  },
60
62
  "input": {
61
- "description": "Explicitly defines the input data passed into the transform. Docs specify an object; in practice this is a nested transform object. If omitted, the transform uses the UI-configured source+attribute input.",
62
- "oneOf": [
63
+ "description": "Explicitly defines the input data passed into the transform. Can be a static date string matching the inputFormat pattern, or a nested transform object whose output is a date string. NOTE: The 'now' keyword is NOT supported by dateFormat — use dateMath instead for current-time operations.",
64
+ "anyOf": [
65
+ {
66
+ "type": "string",
67
+ "description": "Static date string that matches the inputFormat pattern (e.g., '4/1/1975' for inputFormat 'M/d/yyyy', or '144642632190' for EPOCH_TIME_JAVA). The 'now' keyword is NOT supported."
68
+ },
63
69
  {
64
70
  "$ref": "#/$defs/NestedTransform"
65
71
  }
@@ -71,7 +77,7 @@
71
77
  "$defs": {
72
78
  "NestedTransform": {
73
79
  "type": "object",
74
- "description": "Nested transform object used for explicit input.",
80
+ "description": "A nested transform object whose output is used as the input date string for this dateFormat transform. Common use: accountAttribute to pull a date from a source, or dateMath to compute a relative date. The 'name' field is optional in nested transforms per SailPoint docs.",
75
81
  "additionalProperties": false,
76
82
  "required": [
77
83
  "type",
@@ -79,18 +85,22 @@
79
85
  ],
80
86
  "properties": {
81
87
  "id": {
82
- "type": "string"
88
+ "type": "string",
89
+ "description": "Optional ID when referencing an existing saved transform."
83
90
  },
84
91
  "name": {
85
92
  "type": "string",
86
- "minLength": 1
93
+ "minLength": 1,
94
+ "description": "Optional display name for the nested transform."
87
95
  },
88
96
  "type": {
89
97
  "type": "string",
90
- "minLength": 1
98
+ "minLength": 1,
99
+ "description": "The operation type of the nested transform (e.g., 'accountAttribute', 'dateMath', 'static')."
91
100
  },
92
101
  "requiresPeriodicRefresh": {
93
- "type": "boolean"
102
+ "type": "boolean",
103
+ "description": "Whether this nested transform re-evaluates during nightly refresh."
94
104
  },
95
105
  "attributes": {
96
106
  "type": "object",
@@ -102,20 +112,52 @@
102
112
  },
103
113
  "examples": [
104
114
  {
115
+ "name": "Java Epoch to ISO8601",
105
116
  "type": "dateFormat",
106
- "name": "Date Format Transform",
107
117
  "attributes": {
108
118
  "inputFormat": "EPOCH_TIME_JAVA",
109
119
  "outputFormat": "ISO8601"
110
120
  }
111
121
  },
112
122
  {
123
+ "name": "US Date to Database Format",
113
124
  "type": "dateFormat",
114
- "name": "Date Format Transform (US date to DB)",
115
125
  "attributes": {
116
126
  "inputFormat": "M/d/yyyy",
117
127
  "outputFormat": "yyyy-MM-dd"
118
128
  }
129
+ },
130
+ {
131
+ "name": "Static Date String Conversion",
132
+ "type": "dateFormat",
133
+ "attributes": {
134
+ "input": "12/31/1995",
135
+ "inputFormat": "M/d/yyyy",
136
+ "outputFormat": "ISO8601"
137
+ }
138
+ },
139
+ {
140
+ "name": "Source Hire Date to ISO8601",
141
+ "type": "dateFormat",
142
+ "attributes": {
143
+ "input": {
144
+ "type": "accountAttribute",
145
+ "attributes": {
146
+ "sourceName": "HR Source",
147
+ "attributeName": "hire_date"
148
+ }
149
+ },
150
+ "inputFormat": "MM/dd/yyyy",
151
+ "outputFormat": "ISO8601"
152
+ }
153
+ },
154
+ {
155
+ "name": "PeopleSoft Date to LDAP Format",
156
+ "type": "dateFormat",
157
+ "attributes": {
158
+ "inputFormat": "PEOPLE_SOFT",
159
+ "outputFormat": "LDAP"
160
+ }
119
161
  }
120
162
  ]
121
163
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "sailpoint.isc.transforms.dateMath.schema.json",
4
4
  "title": "SailPoint ISC Transform Schema - dateMath",
5
- "description": "Strict schema derived from the SailPoint official Date Math operation documentation. Adds/subtracts/rounds components of an incoming ISO8601 UTC timestamp, or uses the keyword 'now'. Other considerations from docs: input must be ISO8601 UTC; rounding by 'week' is not supported; if 'now' is used and an 'input' attribute is also provided, the transform prefers 'now' and ignores 'input'. Output format is yyyy-MM-dd'T'HH:mm (not ISO8601); convert to ISO8601 via dateFormat when embedding in other transforms.",
5
+ "description": "Strict schema for the SailPoint ISC Date Math transform. Adds, subtracts, and rounds components of a datetime using 'now' or an explicit ISO8601 UTC input. IMPORTANT CONSTRAINTS: (1) Input must be ISO8601 UTC format (yyyy-MM-dd'T'HH:mm:ss.SSSZ). (2) Output format is 'yyyy-MM-dd\\'T\\'HH:mm' NOT full ISO8601; wrap with dateFormat outputFormat: ISO8601 when nesting in other transforms. (3) Week rounding (/w) is not supported. (4) If expression uses 'now' and an input attribute is also provided, 'now' takes precedence and input is ignored.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -12,16 +12,18 @@
12
12
  ],
13
13
  "properties": {
14
14
  "type": {
15
- "const": "dateMath"
15
+ "const": "dateMath",
16
+ "description": "Transform operation type. Must be exactly 'dateMath'."
16
17
  },
17
18
  "name": {
18
19
  "type": "string",
19
- "minLength": 1
20
+ "minLength": 1,
21
+ "description": "Display name for this transform, shown in UI dropdowns and identity profile mappings."
20
22
  },
21
23
  "requiresPeriodicRefresh": {
22
24
  "type": "boolean",
23
25
  "default": false,
24
- "description": "Whether the transform logic should be reevaluated nightly as part of identity refresh. Default false."
26
+ "description": "If true, re-evaluates this transform during the nightly identity refresh cycle. RECOMMENDED when expression uses 'now', so the computed date stays current. Default is false."
25
27
  },
26
28
  "attributes": {
27
29
  "type": "object",
@@ -33,21 +35,17 @@
33
35
  "expression": {
34
36
  "type": "string",
35
37
  "minLength": 1,
36
- "description": "Date-math expression. Allowed units: y, M, w, d, h, m, s and keyword 'now'. Allowed operators: +, -, / (round). Docs note rounding with week is not supported.",
37
- "pattern": "^(?!.*\\/w)(?:now|[0-9yMwdhms+\\-\\/]+)$"
38
+ "pattern": "^(?!\\.*/w)(now[+\\-\\/0-9yMwdhms]*|[+\\-\\/][0-9yMwdhms+\\-\\/]*)$",
39
+ "description": "Date-math expression defining the operation. Valid time units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second). Keyword 'now' = current datetime (must be at start). Operators: +(add) -(subtract) /(round — must be last, NOT /w). No whitespace allowed. Examples: 'now' | 'now/h' | 'now-5d/d' | 'now+1w' | '+3M' | '+12h/s' | 'now+1y+1M+2d-4h+1m-3s/s'. NOTE: output is yyyy-MM-dd'T'HH:mm — wrap with dateFormat for ISO8601."
38
40
  },
39
41
  "roundUp": {
40
42
  "type": "boolean",
41
43
  "default": false,
42
- "description": "When expression includes rounding '/', true rounds up (truncate + add one unit); false truncates only. Default false."
44
+ "description": "Controls rounding direction when the '/' operator is used. true = round up (truncate to unit then add one unit). false = round down (truncate only, default). Has NO effect if expression does not contain '/'."
43
45
  },
44
46
  "input": {
45
- "description": "Explicitly defines the input data passed into the transform (nested transform object). If omitted, uses UI-configured input.",
46
- "oneOf": [
47
- {
48
- "$ref": "#/$defs/NestedTransform"
49
- }
50
- ]
47
+ "description": "Nested transform providing the input datetime. Output of the nested transform must be ISO8601 UTC (yyyy-MM-dd'T'HH:mm:ss.SSSZ). Use a dateFormat transform with outputFormat: 'ISO8601' if the source attribute is not already ISO8601. IGNORED if expression contains 'now'.",
48
+ "$ref": "#/$defs/NestedTransform"
51
49
  }
52
50
  }
53
51
  }
@@ -55,7 +53,7 @@
55
53
  "$defs": {
56
54
  "NestedTransform": {
57
55
  "type": "object",
58
- "description": "Nested transform object used as explicit input. Docs examples frequently omit 'name' for nested transforms.",
56
+ "description": "A nested transform object whose output is the input datetime for this dateMath transform. Must produce an ISO8601 UTC string. Commonly wraps an accountAttribute through a dateFormat transform. The 'name' field is optional in nested transforms per SailPoint docs.",
59
57
  "additionalProperties": false,
60
58
  "required": [
61
59
  "type",
@@ -63,18 +61,22 @@
63
61
  ],
64
62
  "properties": {
65
63
  "id": {
66
- "type": "string"
64
+ "type": "string",
65
+ "description": "Optional ID when referencing an existing saved transform."
67
66
  },
68
67
  "name": {
69
68
  "type": "string",
70
- "minLength": 1
69
+ "minLength": 1,
70
+ "description": "Optional display name for the nested transform."
71
71
  },
72
72
  "type": {
73
73
  "type": "string",
74
- "minLength": 1
74
+ "minLength": 1,
75
+ "description": "The operation type of the nested transform (e.g., 'dateFormat', 'accountAttribute')."
75
76
  },
76
77
  "requiresPeriodicRefresh": {
77
- "type": "boolean"
78
+ "type": "boolean",
79
+ "description": "Whether this nested transform re-evaluates during nightly refresh."
78
80
  },
79
81
  "attributes": {
80
82
  "type": "object",
@@ -86,16 +88,33 @@
86
88
  },
87
89
  "examples": [
88
90
  {
91
+ "name": "Five Days Ago Rounded to Day",
89
92
  "type": "dateMath",
90
- "name": "Date Math Transform",
93
+ "requiresPeriodicRefresh": true,
91
94
  "attributes": {
92
95
  "expression": "now-5d/d",
93
96
  "roundUp": false
94
97
  }
95
98
  },
96
99
  {
100
+ "name": "Current Time Rounded to Hour",
101
+ "type": "dateMath",
102
+ "requiresPeriodicRefresh": true,
103
+ "attributes": {
104
+ "expression": "now/h"
105
+ }
106
+ },
107
+ {
108
+ "name": "One Week From Now",
109
+ "type": "dateMath",
110
+ "requiresPeriodicRefresh": true,
111
+ "attributes": {
112
+ "expression": "now+1w"
113
+ }
114
+ },
115
+ {
116
+ "name": "Add 12 Hours to Source Date Rounded to Second",
97
117
  "type": "dateMath",
98
- "name": "Date Math Transform (Explicit Input)",
99
118
  "attributes": {
100
119
  "expression": "+12h/s",
101
120
  "roundUp": true,
@@ -114,6 +133,14 @@
114
133
  }
115
134
  }
116
135
  }
136
+ },
137
+ {
138
+ "name": "Complex: Add 1 Year 1 Month 2 Days Minus 4 Hours Rounded to Second",
139
+ "type": "dateMath",
140
+ "requiresPeriodicRefresh": true,
141
+ "attributes": {
142
+ "expression": "now+1y+1M+2d-4h+1m-3s/s"
143
+ }
117
144
  }
118
145
  ]
119
146
  }
@@ -399,27 +399,34 @@ function lintReplaceAll(attrs) {
399
399
  function lintDateMath(attrs) {
400
400
  const msgs = [];
401
401
  const expr = attrs?.expression;
402
+ let startsWithNow = false;
403
+ let sawRound = false;
402
404
  if (expr !== undefined) {
403
405
  if (typeof expr !== "string" || expr.trim().length === 0) {
404
406
  push(msgs, "error", "expression must be a non-empty string.", "attributes.expression");
405
407
  }
406
408
  else {
407
409
  const s = expr.trim();
410
+ // --- No whitespace allowed ---
408
411
  if (/\s/.test(s)) {
409
412
  push(msgs, "error", "expression must not contain whitespace.", "attributes.expression");
410
413
  }
414
+ // --- Valid character set: digits, y M w d h m s, n o w (for 'now'), +, -, / ---
411
415
  if (!/^[0-9yMwdhmsnow+\-/]+$/.test(s)) {
412
- push(msgs, "error", "expression contains invalid characters. Allowed: digits, y M w d h m s, now, +, -, /.", "attributes.expression");
416
+ push(msgs, "error", "expression contains invalid characters. " +
417
+ "Allowed units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second), keyword 'now', operators: + - /.", "attributes.expression");
413
418
  }
414
419
  let i = 0;
415
420
  const n = s.length;
416
- const startsWithNow = s.startsWith("now");
421
+ startsWithNow = s.startsWith("now");
417
422
  if (startsWithNow)
418
423
  i += 3;
424
+ // 'now' must only appear at the start
419
425
  if (!startsWithNow && s.includes("now")) {
420
- push(msgs, "error", "'now' keyword must appear only at the start of the expression.", "attributes.expression");
426
+ push(msgs, "error", "'now' keyword must appear only at the start of the expression (e.g., 'now-5d/d', 'now+1w').", "attributes.expression");
421
427
  }
422
- let sawOp = false, sawRound = false, roundUnit = null;
428
+ let sawOp = false;
429
+ let roundUnit = null;
423
430
  const readInt = () => {
424
431
  const start = i;
425
432
  while (i < n && /[0-9]/.test(s[i]))
@@ -438,19 +445,21 @@ function lintDateMath(attrs) {
438
445
  const readSignedTerm = (op) => {
439
446
  const num = readInt();
440
447
  if (!num) {
441
- push(msgs, "error", `Missing integer after '${op}' in expression.`, "attributes.expression");
448
+ push(msgs, "error", `Missing integer after '${op}' in expression. Example: '${op}3d'.`, "attributes.expression");
442
449
  return;
443
450
  }
444
- if (Number(num) === 0)
445
- push(msgs, "warn", `Term '${op}${num}' is a no-op (zero).`, "attributes.expression");
451
+ if (Number(num) === 0) {
452
+ push(msgs, "warn", `Term '${op}${num}' adds/subtracts zero — this is a no-op.`, "attributes.expression");
453
+ }
446
454
  const unit = readUnit();
447
- if (!unit)
448
- push(msgs, "error", `Missing unit after '${op}${num}'. Allowed: y M w d h m s.`, "attributes.expression");
455
+ if (!unit) {
456
+ push(msgs, "error", `Missing time unit after '${op}${num}'. Allowed units: y(year) M(month) w(week) d(day) h(hour) m(minute) s(second).`, "attributes.expression");
457
+ }
449
458
  };
450
459
  const readRound = () => {
451
460
  const unit = readUnit();
452
461
  if (!unit) {
453
- push(msgs, "error", "Rounding '/' must be followed by a unit (y M w d h m s).", "attributes.expression");
462
+ push(msgs, "error", "Rounding operator '/' must be followed by a time unit. Allowed: y M d h m s (NOT w — week rounding is unsupported).", "attributes.expression");
454
463
  return;
455
464
  }
456
465
  sawRound = true;
@@ -460,14 +469,14 @@ function lintDateMath(attrs) {
460
469
  i++;
461
470
  readRound();
462
471
  if (i < n)
463
- push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
472
+ push(msgs, "error", "Rounding '/' must be the last segment of the expression (e.g., 'now-5d/d').", "attributes.expression");
464
473
  }
465
474
  else {
466
475
  while (i < n) {
467
476
  const ch = s[i];
468
477
  if (ch === "+" || ch === "-") {
469
478
  if (sawRound) {
470
- push(msgs, "error", "Add/subtract terms cannot appear after rounding ('/').", "attributes.expression");
479
+ push(msgs, "error", "Add/subtract terms cannot appear after the rounding operator ('/').", "attributes.expression");
471
480
  break;
472
481
  }
473
482
  sawOp = true;
@@ -477,43 +486,75 @@ function lintDateMath(attrs) {
477
486
  }
478
487
  if (ch === "/") {
479
488
  if (sawRound) {
480
- push(msgs, "error", "Only one rounding operator '/' is supported.", "attributes.expression");
489
+ push(msgs, "error", "Only one rounding operator '/' is allowed per expression.", "attributes.expression");
481
490
  break;
482
491
  }
483
492
  i++;
484
493
  readRound();
485
494
  if (i < n)
486
- push(msgs, "error", "Rounding must be the last part of the expression.", "attributes.expression");
495
+ push(msgs, "error", "Rounding '/' must be the last segment of the expression.", "attributes.expression");
487
496
  break;
488
497
  }
489
- push(msgs, "error", `Unexpected token '${ch}'. Use patterns like 'now-5d/d' or '+3M/h'.`, "attributes.expression");
498
+ push(msgs, "error", `Unexpected token '${ch}' in expression '${s}'. ` +
499
+ "Valid forms: 'now', 'now-5d/d', 'now+1y+1M', '+3M', '+12h/s'.", "attributes.expression");
490
500
  break;
491
501
  }
492
502
  }
503
+ // Expression must start with 'now', +, -, or /
493
504
  if (!startsWithNow && s !== "" && s[0] !== "+" && s[0] !== "-" && s[0] !== "/") {
494
- push(msgs, "error", "Expression must start with 'now', '+', '-', or '/'.", "attributes.expression");
505
+ push(msgs, "error", `Expression must start with 'now', '+', '-', or '/'. Got: '${s}'. ` +
506
+ "Examples: 'now-5d/d', '+3M', '-1y/d'.", "attributes.expression");
495
507
  }
496
- // Week rounding not supported (SailPoint docs)
508
+ // Week rounding is explicitly unsupported per docs
497
509
  if (roundUnit === "w") {
498
- push(msgs, "error", "Rounding with 'w' (week) is not supported in dateMath expressions.", "attributes.expression");
510
+ push(msgs, "error", "Rounding with 'w' (week) is not supported by SailPoint dateMath and will produce an error at runtime. " +
511
+ "Use a different unit for rounding (y, M, d, h, m, s).", "attributes.expression");
499
512
  }
513
+ // Expression without 'now' and no ops is invalid
500
514
  if (!startsWithNow && !sawOp && !sawRound) {
501
- push(msgs, "error", "Expression must contain 'now', at least one +/- term, or a rounding segment.", "attributes.expression");
515
+ push(msgs, "error", "Expression must contain 'now', at least one +/- term, or a rounding segment. " +
516
+ "Examples: 'now', 'now-5d/d', '+3M/h'.", "attributes.expression");
502
517
  }
518
+ // Expression without 'now' requires an input date
503
519
  if (!startsWithNow && attrs?.input === undefined) {
504
- push(msgs, "error", "dateMath expression without 'now' requires an input date transform in attributes.input.", "attributes.input");
520
+ push(msgs, "error", "dateMath expression without 'now' requires an explicit input date via attributes.input (nested transform). " +
521
+ "The input must produce an ISO8601 UTC datetime. Use a dateFormat transform with outputFormat: 'ISO8601' if needed.", "attributes.input");
522
+ }
523
+ // Output format info — dateMath output is yyyy-MM-dd'T'HH:mm, not full ISO8601
524
+ push(msgs, "info", "dateMath output format is 'yyyy-MM-dd\\'T\\'HH:mm' — this is NOT full ISO8601. " +
525
+ "If this transform feeds into another transform that expects ISO8601 (e.g., dateCompare), " +
526
+ "wrap it with a dateFormat transform using outputFormat: 'ISO8601'.", "attributes.expression");
527
+ // Recommend requiresPeriodicRefresh when 'now' is used
528
+ if (startsWithNow) {
529
+ push(msgs, "info", "Expression uses 'now'. Set requiresPeriodicRefresh: true at the transform root level " +
530
+ "so the date re-evaluates during nightly identity refresh — otherwise results may become stale.", "attributes.expression");
505
531
  }
506
532
  }
507
533
  }
534
+ // --- roundUp: boolean check ---
508
535
  if (attrs?.roundUp !== undefined && typeof attrs.roundUp !== "boolean") {
509
- push(msgs, "error", "roundUp must be a boolean.", "attributes.roundUp");
536
+ push(msgs, "error", "roundUp must be a boolean. true = round up (truncate + add one unit); false = truncate only (default).", "attributes.roundUp");
510
537
  }
538
+ // --- roundUp without rounding operator has no effect ---
539
+ if (attrs?.roundUp === true &&
540
+ typeof expr === "string" &&
541
+ !expr.includes("/")) {
542
+ push(msgs, "warn", "roundUp is true but expression contains no rounding operator '/'. " +
543
+ "roundUp has no effect without '/'. Add a rounding segment (e.g., '/d') or remove roundUp.", "attributes.roundUp");
544
+ }
545
+ // --- input: must be a nested transform object ---
511
546
  if (attrs?.input !== undefined) {
512
547
  const inp = attrs.input;
513
548
  if (!(inp && typeof inp === "object" && typeof inp.type === "string")) {
514
- push(msgs, "warn", "input should be a nested transform object {type, attributes}.", "attributes.input");
549
+ push(msgs, "warn", "input must be a nested transform object {type, attributes} that produces an ISO8601 UTC datetime. " +
550
+ "Use a dateFormat transform with outputFormat: 'ISO8601' if the source attribute is not already ISO8601.", "attributes.input");
515
551
  }
516
552
  }
553
+ // --- 'now' + input conflict warning ---
554
+ if (startsWithNow && attrs?.input !== undefined) {
555
+ push(msgs, "warn", "Expression uses 'now' and an input attribute is also provided. " +
556
+ "Per SailPoint docs, when 'now' is in the expression the transform ignores the input attribute entirely.", "attributes.input");
557
+ }
517
558
  return msgs;
518
559
  }
519
560
  // ---------------------------------------------------------------------------
@@ -618,7 +659,17 @@ function lintDateFormat(attrs) {
618
659
  const msgs = [];
619
660
  // Exact set of named formats per SailPoint docs — no variants accepted.
620
661
  const NAMED_FORMATS = new Set(["ISO8601", "LDAP", "PEOPLE_SOFT", "EPOCH_TIME_JAVA", "EPOCH_TIME_WIN32"]);
621
- // A value that is ALL_CAPS_WITH_UNDERSCORES is clearly intended as a named constant, not a pattern.
662
+ // Human-readable descriptions for each named format (shown as info when used correctly)
663
+ const NAMED_FORMAT_DESCRIPTIONS = {
664
+ ISO8601: "yyyy-MM-dd'T'HH:mm:ss.SSSZ — ISO 8601 standard datetime",
665
+ LDAP: "yyyyMMddHHmmss.Z — LDAP directory format",
666
+ PEOPLE_SOFT: "MM/dd/yyyy — PeopleSoft system format",
667
+ EPOCH_TIME_JAVA: "milliseconds since Jan 1, 1970 (Java epoch) — input must be a numeric string",
668
+ EPOCH_TIME_WIN32: "100-nanosecond intervals since Jan 1, 1601 (Windows/Win32 epoch) — input must be a numeric string",
669
+ };
670
+ // ALL_CAPS_WITH_UNDERSCORES values are clearly intended as named constants, not patterns.
671
+ // Do NOT fall through to isLikelyPattern — e.g. EPOCH_TIME_JAVA_IN_MILLIS contains
672
+ // 'H' and 'M' and would falsely pass the pattern check.
622
673
  const looksLikeNamedConstant = (s) => /^[A-Z][A-Z0-9_]+$/.test(s);
623
674
  const isLikelyPattern = (s) => /[yMdHhmsSZ]/.test(s);
624
675
  const checkFmt = (field) => {
@@ -626,35 +677,59 @@ function lintDateFormat(attrs) {
626
677
  if (raw === undefined)
627
678
  return;
628
679
  if (typeof raw !== "string") {
629
- push(msgs, "error", `${field} must be a string (named format or pattern).`, `attributes.${field}`);
680
+ push(msgs, "error", `${field} must be a string — either a named format (${Array.from(NAMED_FORMATS).join(", ")}) or a Java SimpleDateFormat pattern.`, `attributes.${field}`);
630
681
  return;
631
682
  }
632
683
  const t = raw.trim();
633
- // If the value looks like a named constant (ALL_CAPS_UNDERSCORES), validate strictly.
634
- // Do NOT fall through to isLikelyPattern — e.g. EPOCH_TIME_JAVA_IN_MILLIS contains
635
- // 'H' and 'M' and would falsely pass the pattern check.
636
684
  if (looksLikeNamedConstant(t)) {
637
685
  if (!NAMED_FORMATS.has(t)) {
638
686
  push(msgs, "error", `'${t}' is not a valid named format for ${field}. ` +
639
687
  `Allowed named formats: ${Array.from(NAMED_FORMATS).join(", ")}. ` +
640
- `Alternatively, use a Java SimpleDateFormat pattern (e.g. dd-MM-yyyy, yyyy-MM-dd'T'HH:mm:ssZ).`, `attributes.${field}`);
688
+ `Alternatively, use a Java SimpleDateFormat pattern (e.g. 'dd-MM-yyyy', 'yyyy-MM-dd\\'T\\'HH:mm:ssZ').`, `attributes.${field}`);
689
+ }
690
+ else {
691
+ push(msgs, "info", `${field} '${t}' → ${NAMED_FORMAT_DESCRIPTIONS[t]}.`, `attributes.${field}`);
641
692
  }
642
693
  return;
643
694
  }
644
- // Value looks like a date pattern — check it has at least one date token.
695
+ // Value looks like a date pattern — check it has at least one recognisable date token.
645
696
  if (!isLikelyPattern(t)) {
646
- push(msgs, "warn", `${field} '${t}' doesn't match a known named format and doesn't look like a date pattern (missing tokens like y/M/d/H).`, `attributes.${field}`);
697
+ push(msgs, "warn", `${field} '${t}' doesn't match a known named format and doesn't look like a Java SimpleDateFormat pattern ` +
698
+ "(expected tokens like y, M, d, H, h, m, s, S, Z). " +
699
+ `Valid named formats: ${Array.from(NAMED_FORMATS).join(", ")}.`, `attributes.${field}`);
647
700
  }
648
701
  };
649
702
  checkFmt("inputFormat");
650
703
  checkFmt("outputFormat");
704
+ // --- EPOCH format input reminders ---
705
+ const inFmt = attrs?.inputFormat;
706
+ if (typeof inFmt === "string" && (inFmt === "EPOCH_TIME_JAVA" || inFmt === "EPOCH_TIME_WIN32")) {
707
+ push(msgs, "info", `inputFormat '${inFmt}' expects a numeric string as input — ` +
708
+ (inFmt === "EPOCH_TIME_JAVA"
709
+ ? "milliseconds since Jan 1, 1970 (e.g., '1609459200000')."
710
+ : "100-nanosecond intervals since Jan 1, 1601 (Windows FILETIME)."), "attributes.inputFormat");
711
+ }
712
+ // --- input: validate type, reject 'now', guide on valid forms ---
651
713
  if (attrs?.input !== undefined) {
652
714
  const inp = attrs.input;
653
- if (typeof inp === "object" && inp !== null && typeof inp.type !== "string") {
654
- push(msgs, "warn", "input looks like an object but is missing a nested transform 'type'.", "attributes.input");
715
+ if (typeof inp === "string") {
716
+ // 'now' is explicitly NOT supported by dateFormat per official docs
717
+ if (inp.trim().toLowerCase() === "now") {
718
+ push(msgs, "error", "dateFormat does not support 'now' as an input value (official docs limitation). " +
719
+ "To derive a date from the current time, use a dateMath transform producing an ISO8601 string " +
720
+ "and reference it as a nested transform in the input field.", "attributes.input");
721
+ }
722
+ // A static date string is valid — no further warning needed
655
723
  }
656
- else if (typeof inp !== "string" && !isPlainObject(inp)) {
657
- push(msgs, "warn", "input should be a string or a nested transform object.", "attributes.input");
724
+ else if (isPlainObject(inp)) {
725
+ if (typeof inp.type !== "string") {
726
+ push(msgs, "warn", "input is an object but is missing a 'type' field — it does not look like a valid nested transform. " +
727
+ "Add a 'type' (e.g., 'accountAttribute', 'dateMath') to make it a proper nested transform.", "attributes.input");
728
+ }
729
+ // Otherwise it's a well-formed nested transform — no warning
730
+ }
731
+ else {
732
+ push(msgs, "warn", "input should be a static date string matching inputFormat, or a nested transform object {type, attributes}.", "attributes.input");
658
733
  }
659
734
  }
660
735
  return msgs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isc-transforms-mcp",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "type": "module",
5
5
  "description": "MCP server for SailPoint Identity Security Cloud (ISC) Transform authoring — scaffold, strict lint, catalog, and safe upsert to live tenants.",
6
6
  "author": {