pbiplint 0.2.2 → 0.2.3

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/pbiplint.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // ../core/src/version.ts
4
- var VERSION = "0.2.2";
4
+ var VERSION = "0.2.3";
5
5
 
6
6
  // ../core/src/engine/config.ts
7
7
  var ConfigError = class extends Error {
@@ -261,6 +261,17 @@ function buildCalculationGroup(cg, table) {
261
261
  }
262
262
  return group;
263
263
  }
264
+ var CALENDAR_COLUMN_PROPS = /* @__PURE__ */ new Set(["primarycolumn", "associatedcolumn", "column"]);
265
+ function buildCalendar(cal, table) {
266
+ const columns2 = [];
267
+ for (const group of cal.children)
268
+ if (group.type === "calendarcolumngroup") {
269
+ for (const p of group.children)
270
+ if (p.kind === "prop" && CALENDAR_COLUMN_PROPS.has(p.type) && p.value !== void 0)
271
+ columns2.push(unquoteName(p.value));
272
+ }
273
+ return { ...named(cal), table, columns: columns2 };
274
+ }
264
275
  function buildTable(r, model) {
265
276
  let t = model.tables.find((x) => x.name === r.name);
266
277
  if (!t) {
@@ -272,7 +283,8 @@ function buildTable(r, model) {
272
283
  columns: [],
273
284
  measures: [],
274
285
  partitions: [],
275
- hierarchies: []
286
+ hierarchies: [],
287
+ calendars: []
276
288
  };
277
289
  model.tables.push(t);
278
290
  } else {
@@ -287,6 +299,8 @@ function buildTable(r, model) {
287
299
  else if (c.kind === "object" && c.type === "partition") t.partitions.push(buildPartition(c, t));
288
300
  else if (c.kind === "object" && c.type === "hierarchy")
289
301
  t.hierarchies.push(buildHierarchy(c, t));
302
+ else if (c.kind === "object" && c.type === "calendar")
303
+ (t.calendars ??= []).push(buildCalendar(c, t));
290
304
  else if (c.type === "calculationgroup") {
291
305
  const group = buildCalculationGroup(c, t);
292
306
  const first = t.calculationGroup;
@@ -299,6 +313,34 @@ function buildTable(r, model) {
299
313
  }
300
314
  }
301
315
  }
316
+ function buildCulture(r) {
317
+ const block = r.children.find((c) => c.type === "translations");
318
+ if (!block) return named(r);
319
+ const translations = {
320
+ tables: [],
321
+ columns: [],
322
+ measures: [],
323
+ hierarchies: [],
324
+ levels: []
325
+ };
326
+ const captioned = (n2) => (str(n2.props.caption) ?? "").trim() !== "";
327
+ for (const m of objects(block, "model"))
328
+ for (const t of objects(m, "table")) {
329
+ const table = t.name ?? "";
330
+ if (captioned(t)) translations.tables.push(table);
331
+ for (const c of objects(t, "column"))
332
+ if (captioned(c)) translations.columns.push({ table, name: c.name ?? "" });
333
+ for (const x of objects(t, "measure"))
334
+ if (captioned(x)) translations.measures.push({ table, name: x.name ?? "" });
335
+ for (const h of objects(t, "hierarchy")) {
336
+ const hierarchy = h.name ?? "";
337
+ if (captioned(h)) translations.hierarchies.push({ table, name: hierarchy });
338
+ for (const l of objects(h, "level"))
339
+ if (captioned(l)) translations.levels.push({ table, hierarchy, name: l.name ?? "" });
340
+ }
341
+ }
342
+ return { ...named(r), translations };
343
+ }
302
344
  function buildRelationship(r) {
303
345
  const p = r.props;
304
346
  const from = splitQualifiedName(str(p.fromcolumn) ?? "");
@@ -397,7 +439,7 @@ function readDeclaration(r, model) {
397
439
  break;
398
440
  }
399
441
  case "cultureinfo":
400
- model.cultures.push(named(r));
442
+ model.cultures.push(buildCulture(r));
401
443
  break;
402
444
  case "expression":
403
445
  model.expressions.push({ ...named(r), expression: r.value ?? "" });
@@ -521,6 +563,9 @@ function buildReachabilityIndex(model, references, reportRefs) {
521
563
  for (const c of t.columns)
522
564
  for (const v of c.variations)
523
565
  if (v.defaultColumn) reach(columnOf(v.defaultColumn.table, v.defaultColumn.column), null);
566
+ for (const t of model.tables)
567
+ for (const cal of t.calendars ?? [])
568
+ for (const name of cal.columns) reach(columnOf(t.name, name), null);
524
569
  for (const t of model.tables) for (const c of t.columns) if (c.alternateOf) reach(c, null);
525
570
  while (queue.length) {
526
571
  const n2 = queue.shift();
@@ -1166,7 +1211,10 @@ function buildUsageIndex(model) {
1166
1211
  const levelColumns = /* @__PURE__ */ new Set();
1167
1212
  const variationDefaults = /* @__PURE__ */ new Set();
1168
1213
  const groupByTargets = /* @__PURE__ */ new Set();
1214
+ const calendarColumns = /* @__PURE__ */ new Set();
1169
1215
  for (const t of model.tables) {
1216
+ for (const cal of t.calendars ?? [])
1217
+ for (const c of cal.columns) calendarColumns.add(key3(t.name, c));
1170
1218
  for (const c of t.columns) {
1171
1219
  if (c.sortByColumn !== void 0) sortTargets.add(key3(t.name, c.sortByColumn));
1172
1220
  for (const g of c.groupByColumns) groupByTargets.add(key3(t.name, g));
@@ -1181,7 +1229,8 @@ function buildUsageIndex(model) {
1181
1229
  usedInSortBy: (c) => sortTargets.has(key3(c.table.name, c.name)),
1182
1230
  usedInHierarchies: (c) => levelColumns.has(key3(c.table.name, c.name)),
1183
1231
  usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name)),
1184
- usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name))
1232
+ usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name)),
1233
+ usedInCalendars: (c) => calendarColumns.has(key3(c.table.name, c.name))
1185
1234
  };
1186
1235
  }
1187
1236
 
@@ -2070,6 +2119,7 @@ var allPartitions = (m) => m.tables.flatMap((t) => t.partitions);
2070
2119
  var allCalculationItems = (m) => m.tables.flatMap((t) => t.calculationGroup?.items ?? []);
2071
2120
  var allTablePermissions = (m) => m.roles.flatMap((r) => r.tablePermissions);
2072
2121
  var dataType = (c) => (c.dataType ?? "").toLowerCase();
2122
+ var typeKnown = (c) => dataType(c) !== "";
2073
2123
  var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
2074
2124
  var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
2075
2125
  var isBlank = (s) => s === void 0 || s.trim() === "";
@@ -3502,10 +3552,11 @@ var RULE_SUMMARIES = {
3502
3552
  "CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": "Every relationship that is bi-directional, many-to-many, or both. This is a review list at info severity, not a defect.",
3503
3553
  "CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY": "Row-level security filters that call USERNAME or USERPRINCIPALNAME. Reported per table permission, at info severity.",
3504
3554
  DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN: "Data columns with no source column. Calculated columns are not checked.",
3505
- "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "Tables with date or calendar in the name that are not marked as a date table, meaning the data category is not Time or no DateTime column is marked as the key.",
3555
+ "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "Tables with date or calendar in the name that define no calendar and are not marked as a date table, meaning the data category is not Time or no DateTime column is marked as the key.",
3506
3556
  DATECOLUMN_FORMATSTRING: "DateTime columns with date in the name whose format string is not exactly `mm/dd/yyyy`.",
3507
3557
  DAX_COLUMNS_FULLY_QUALIFIED: "Measures and row-level security filters that refer to a column by its bare name, `[Column]`, instead of `'Table'[Column]`.",
3508
3558
  DAX_MEASURES_UNQUALIFIED: "Measures, calculated columns, calculated tables, and calculation items that refer to a measure with a table prefix, `'Table'[Measure]`.",
3559
+ DECIMAL_COLUMN_WITHOUT_FORMAT_STRING: "Visible Decimal number and Fixed decimal number columns with no format string.",
3509
3560
  DEFAULT_PAGE_NAME: "Pages whose display name in page.json has the shape `Page <n>`, `Duplicate of <name>`, or `<name> (copy)`: the names English Power BI Desktop gives a new page and a duplicated one, and a name marked as a copy. The rule also matches the forms Desktop-saved files show for a new or duplicated page in other languages, such as `Seite <n>` and `Doublon de <name>`.",
3510
3561
  ENSURE_ALTTEXT: "Visuals other than shapes whose alt text is missing or empty. A visual group is checked by its own alt text, the one set on the group rather than on the visuals inside it.",
3511
3562
  ENSURE_PAGES_DO_NOT_SCROLL_VERTICALLY: "Visible pages taller than the threshold, 720 pixels by default.",
@@ -3527,7 +3578,7 @@ var RULE_SUMMARIES = {
3527
3578
  HIDE_TOOLTIP_DRILLTROUGH_PAGES: "Tooltip pages and drillthrough pages that are not hidden.",
3528
3579
  INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure, calculation item, or user-defined function activates with USERELATIONSHIP.",
3529
3580
  INTEGER_FORMATTING: "Measures whose static format string is not a recognized whole-number, currency, or percentage format. The only format strings the rule accepts are `#,0`, `#,0.0`, and any string containing `$` or `%`. A measure with no format string at all fires too, and that is the common case: the rule reads only the format string, so it cannot tell an unformatted currency or ratio from an unformatted count.",
3530
- ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, or in a variation, and do not themselves sort by another column.",
3581
+ ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, in a variation, or in a calendar, and do not themselves sort by another column.",
3531
3582
  LANDING_PAGE_NOT_SET: "A report whose pages.json sets no landing page, so it opens on the page that was active when it was last saved, or, when pages.json records no active page either, on the first page.",
3532
3583
  LARGE_TABLES_SHOULD_BE_PARTITIONED: "Tables with more than 25 million rows and a single partition. The row count is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
3533
3584
  "LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC": "Tables whose row-level security filter, in any role, calls RIGHT, LEFT, UPPER, LOWER, or FIND.",
@@ -3536,11 +3587,12 @@ var RULE_SUMMARIES = {
3536
3587
  MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: "Measures whose whole expression is a reference to another measure, such as `[Total Sales]`.",
3537
3588
  MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY: "Measures and calculation items that call a time intelligence function, in a model where at least one table is in DirectQuery mode.",
3538
3589
  MINIMIZE_POWER_QUERY_TRANSFORMATIONS: "Power Query partitions whose M text contains Table.Combine, Table.Join, Table.NestedJoin, Table.AddColumn, Table.Group, Table.Sort, Table.Pivot, Table.Unpivot, Table.UnpivotOtherColumns, Table.Distinct, a native SQL query, or an OLE DB or ODBC query.",
3539
- MODEL_SHOULD_HAVE_A_DATE_TABLE: "Models with no table that has the data category Time and a DateTime column marked as the key, which is what Mark as date table sets.",
3590
+ MODEL_SHOULD_HAVE_A_DATE_TABLE: "Models with no table that defines a calendar, and none that has the data category Time and a DateTime column marked as the key, which is what Mark as date table sets.",
3540
3591
  MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: "Models that have at least one DirectQuery table, no aggregation table (no column has an alternateOf mapping), and the PowerBI_V3 data source version, which is every project Desktop writes today.",
3541
3592
  "MONTH_(AS_A_STRING)_MUST_BE_SORTED": "Text columns with month in the name, but not months, that have no sort-by column.",
3542
3593
  MONTHCOLUMN_FORMATSTRING: "DateTime columns with month in the name whose format string is not exactly `MMMM yyyy`.",
3543
- NOT_REACHED_FROM_REPORT: "Columns and measures that nothing in the report reaches, directly or through the model. The walk starts from every field the report names, both columns of every relationship except one to an auto date/time table, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of every variation, the columns of an aggregation table (the ones with an `alternateOf` mapping), the fields the report's own measures reference, and the user-defined functions those measures and the security filters call, and it follows DAX references, calls to user-defined functions, sort-by and group-by columns, the detail column or table each mapping names, and calculated tables until nothing new is reached.",
3594
+ NAME_WITHOUT_TRANSLATION: "Visible tables, columns, measures, and hierarchies, and the levels of visible hierarchies, that a translated culture other than the model's own gives no caption.",
3595
+ NOT_REACHED_FROM_REPORT: "Columns and measures that nothing in the report reaches, directly or through the model. The walk starts from every field the report names, both columns of every relationship except one to an auto date/time table, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of every variation, the columns a calendar names, the columns of an aggregation table (the ones with an `alternateOf` mapping), the fields the report's own measures reference, and the user-defined functions those measures and the security filters call, and it follows DAX references, calls to user-defined functions, sort-by and group-by columns, the detail column or table each mapping names, and calculated tables until nothing new is reached.",
3544
3596
  NUMERIC_COLUMN_SUMMARIZE_BY: "Visible whole number, decimal, or double columns whose default summarization is anything other than None.",
3545
3597
  OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: "Names that start or end with a space, for the model, tables, measures, hierarchies, perspectives, partitions, data columns, and calculated columns.",
3546
3598
  OBJECTS_WITH_NO_DESCRIPTION: "Visible tables, columns, measures, and calculation groups with no description. Visibility is the object's own flag.",
@@ -3549,7 +3601,7 @@ var RULE_SUMMARIES = {
3549
3601
  PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: "Regular tables with exactly one partition whose name differs from the table name. Calculated tables and calculation groups are not checked.",
3550
3602
  PERCENTAGE_FORMATTING: "Measures with a percent format string other than `#,0.0%;-#,0.0%;#,0.0%`.",
3551
3603
  PERSPECTIVES_WITH_NO_OBJECTS: "Perspectives that contain no tables. The rule reads only a perspective's table entries, which is enough: each column, measure, and hierarchy a perspective includes sits under the entry for its table, so with no table entry it includes nothing.",
3552
- PROVIDE_FORMAT_STRING_FOR_MEASURES: "Visible measures with no format string and no dynamic format string.",
3604
+ PROVIDE_FORMAT_STRING_FOR_MEASURES: "Visible measures with no format string and no dynamic format string, other than those whose DAX plainly returns text.",
3553
3605
  REDUCE_ADVANCED_FILTERS: "Pages with more visuals carrying an Advanced filter with a condition applied than the threshold, 4 by default.",
3554
3606
  REDUCE_NUMBER_OF_CALCULATED_COLUMNS: "Models with more than five calculated columns across all tables. Columns of calculated tables do not count, and the finding is on the model.",
3555
3607
  REDUCE_OBJECTS_WITHIN_VISUALS: "Visuals with more entries in their field wells than the threshold, 6 by default, counting each column, measure, visual calculation, or sparkline in any of the visual's wells as one.",
@@ -3567,7 +3619,7 @@ var RULE_SUMMARIES = {
3567
3619
  REMOVE_ROLES_WITH_NO_MEMBERS: "Roles with no members.",
3568
3620
  REMOVE_UNUSED_CUSTOM_VISUALS: "Custom visuals from AppSource that the report registers in report.json and that no visual on any page uses.",
3569
3621
  REPORT_LEVEL_MEASURES: "Measures defined in the report's reportExtensions.json rather than in the model, reported when the model the report reads is in the input, so that each can move into it.",
3570
- SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: "Columns with IsAvailableInMdx set to false that are used to sort another column, appear in a hierarchy or a variation, or sort by another column.",
3622
+ SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: "Columns with IsAvailableInMdx set to false that are used to sort another column, appear in a hierarchy, a variation, or a calendar, or sort by another column.",
3571
3623
  SLICER_SEARCH_SAVED: "Slicers saved with a term in their search box: any visual whose visual.json holds a `selfFilter` with a condition under `objects.general`, which is where Power BI Desktop's saved files keep the text typed in a slicer's search box.",
3572
3624
  SLICER_SELECTION_SAVED: "Slicers saved with a selection, so that the report opens with it applied: any visual whose visual.json holds a `filter` with a condition under `objects.general`, which is where Power BI Desktop saves the selection of a slicer, button slicer, list slicer, input slicer, or `filterSlicer`, and of a custom slicer from AppSource that filters through the Visual Filters API. It is info without a policy, and a warning when the project's policy expects no saved selections.",
3573
3625
  SNOWFLAKE_SCHEMA_ARCHITECTURE: "Tables that are on the from side of one relationship and the to side of another, which is what a dimension related to a sub-dimension looks like.",
@@ -3578,7 +3630,7 @@ var RULE_SUMMARIES = {
3578
3630
  UDF_NOT_CALLED: "User-defined functions that no measure, calculated column, calculated table, calculation item, row-level security filter, format string expression, KPI, or other function in the model calls. The functions of a DAX Lib package, which share one `DAXLIB_PackageId` annotation, count as one: the package is reported once, on its first function, when nothing outside it calls any of them.",
3579
3631
  UDF_USE_COMPOUND_NAMES: "User-defined functions whose name holds neither a dot nor an underscore, such as `AddTax`. Tabular Editor 3 has a built-in rule with the same test.",
3580
3632
  UDF_WITHOUT_DESCRIPTION: "User-defined functions with no description, or one of only spaces, other than functions installed from a DAX Lib package. Tabular Editor 3 has a built-in rule with the same test, which also reports package functions.",
3581
- UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, row-level security filter, or object-level security rule.",
3633
+ UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, calendar, row-level security filter, or object-level security rule.",
3582
3634
  UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
3583
3635
  "UNPIVOT_PIVOTED_(MONTH)_DATA": "Tables that have a numeric column for each of Jan, Feb, Mar, Apr, May, and Jun, matched as substrings of the column names.",
3584
3636
  USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: "Expressions that use the division operator right after a closing bracket or parenthesis, such as `[Sales] / [Cost]` or `SUM(...) / SUM(...)`. A slash that starts a comment is ignored.",
@@ -3702,7 +3754,7 @@ var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
3702
3754
  { skipWhenModelUnread: tablesPartlyRead },
3703
3755
  (m) => columns(
3704
3756
  m,
3705
- (c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && dataType(c) !== "string")
3757
+ (c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && typeKnown(c) && dataType(c) !== "string")
3706
3758
  )
3707
3759
  );
3708
3760
  var DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
@@ -3715,14 +3767,14 @@ var ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
3715
3767
  { skipWhenModelUnread: modelPartlyRead },
3716
3768
  (m, { indexes: { usage } }) => columns(
3717
3769
  m,
3718
- (c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && c.sortByColumn === void 0
3770
+ (c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && !usage.usedInCalendars(c) && c.sortByColumn === void 0
3719
3771
  )
3720
3772
  );
3721
3773
  var SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
3722
3774
  "SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
3723
3775
  (m, { indexes: { usage } }) => columns(
3724
3776
  m,
3725
- (c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || c.sortByColumn !== void 0)
3777
+ (c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || usage.usedInCalendars(c) || c.sortByColumn !== void 0)
3726
3778
  )
3727
3779
  );
3728
3780
  var UNNECESSARY_COLUMNS = bpaRule(
@@ -3736,6 +3788,7 @@ var UNNECESSARY_COLUMNS = bpaRule(
3736
3788
  if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
3737
3789
  if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
3738
3790
  if (indexes.usage.usedInGroupBy(c)) return false;
3791
+ if (indexes.usage.usedInCalendars(c)) return false;
3739
3792
  const bare = `[${c.name}]`.toLowerCase();
3740
3793
  const qualified = [
3741
3794
  `${c.table.name}[${c.name}]`.toLowerCase(),
@@ -3877,11 +3930,50 @@ var patternRule = (id, kinds, patterns) => bpaRule(
3877
3930
  id,
3878
3931
  (m) => expressionObjects(m, kinds).filter((o) => patterns.some((p) => p.test(o.expression))).map((o) => o.finding)
3879
3932
  );
3933
+ var TEXT_FUNCTIONS = /* @__PURE__ */ new Set([
3934
+ "FORMAT",
3935
+ "CONCATENATE",
3936
+ "CONCATENATEX",
3937
+ "UNICHAR",
3938
+ "COMBINEVALUES",
3939
+ "LEFT",
3940
+ "RIGHT",
3941
+ "MID",
3942
+ "UPPER",
3943
+ "LOWER",
3944
+ "SUBSTITUTE",
3945
+ "REPT",
3946
+ "TRIM",
3947
+ "FIXED",
3948
+ "REPLACE",
3949
+ "USERPRINCIPALNAME",
3950
+ "USERNAME",
3951
+ "USEROBJECTID",
3952
+ "USERCULTURE",
3953
+ "CUSTOMDATA",
3954
+ "SELECTEDMEASURENAME",
3955
+ "NAMEOF",
3956
+ "TOJSON",
3957
+ "TOCSV"
3958
+ ]);
3959
+ function returnsText(expression) {
3960
+ const tokens = tokenizeDax(expression);
3961
+ let from = 0;
3962
+ tokens.forEach((t, i) => {
3963
+ if (t.depth === 0 && isWord(t, "RETURN")) from = i + 1;
3964
+ });
3965
+ const result = tokens.slice(from);
3966
+ const first = result[0];
3967
+ if (first === void 0) return false;
3968
+ if (result.length === 1 && first.kind === "string") return true;
3969
+ if (result.some((t) => t.depth === 0 && t.kind === "operator" && t.text === "&")) return true;
3970
+ return first.kind === "identifier" && TEXT_FUNCTIONS.has(first.text.toUpperCase()) && isPunctuation(result[1], "(");
3971
+ }
3880
3972
  var PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
3881
3973
  "PROVIDE_FORMAT_STRING_FOR_MEASURES",
3882
3974
  { skipWhenModelUnread: tablesPartlyRead },
3883
3975
  (m) => allMeasures(m).filter(
3884
- (x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition)
3976
+ (x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition) && !returnsText(x.expression)
3885
3977
  ).map(finding.measure)
3886
3978
  );
3887
3979
  var formatStringDetail = (x) => {
@@ -4049,7 +4141,7 @@ var isBidirectional = (r) => r.crossFilteringBehavior === "bothdirections";
4049
4141
  var RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE = bpaRule(
4050
4142
  "RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE",
4051
4143
  (m, { indexes: { relationships } }) => allColumns(m).filter(
4052
- (c) => relationships.forColumn(c.table.name, c.name).length > 0 && dataType(c) !== "int64"
4144
+ (c) => relationships.forColumn(c.table.name, c.name).length > 0 && typeKnown(c) && dataType(c) !== "int64"
4053
4145
  ).map(finding.column)
4054
4146
  );
4055
4147
  var HIDE_FOREIGN_KEYS = bpaRule(
@@ -4106,7 +4198,7 @@ var RELATIONSHIP_COLUMNS_SAME_DATA_TYPE = bpaRule(
4106
4198
  return m.relationships.filter((r) => {
4107
4199
  const from = column(r.fromTable, r.fromColumn);
4108
4200
  const to = column(r.toTable, r.toColumn);
4109
- return from !== void 0 && to !== void 0 && dataType(from) !== dataType(to);
4201
+ return from !== void 0 && to !== void 0 && typeKnown(from) && typeKnown(to) && dataType(from) !== dataType(to);
4110
4202
  }).map(finding.relationship);
4111
4203
  }
4112
4204
  );
@@ -4164,18 +4256,19 @@ var relationshipRules = [
4164
4256
  ];
4165
4257
 
4166
4258
  // ../core/src/rules/microsoft-bpa/tables.ts
4167
- var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && dataType(c) === "datetime");
4259
+ var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && (dataType(c) === "datetime" || !typeKnown(c)));
4260
+ var hasCalendar = (t) => (t.calendars?.length ?? 0) > 0;
4168
4261
  var MODEL_SHOULD_HAVE_A_DATE_TABLE = bpaRule(
4169
4262
  "MODEL_SHOULD_HAVE_A_DATE_TABLE",
4170
4263
  { skipWhenModelUnread: modelPartlyRead },
4171
- (m) => m.tables.some((t) => t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
4264
+ (m) => m.tables.some((t) => hasCalendar(t) || t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
4172
4265
  );
4173
4266
  var DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE = bpaRule(
4174
4267
  "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
4175
4268
  { skipWhenModelUnread: tablesPartlyRead },
4176
4269
  (m) => tablesInScope(m).filter((t) => {
4177
4270
  const u = t.name.toUpperCase();
4178
- return (u.includes("DATE") || u.includes("CALENDAR")) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
4271
+ return (u.includes("DATE") || u.includes("CALENDAR")) && !hasCalendar(t) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
4179
4272
  }).map(finding.table)
4180
4273
  );
4181
4274
  var REMOVE_AUTO_DATE_TABLE = bpaRule(
@@ -5309,6 +5402,21 @@ var HARDCODED_YEAR_IN_FILTER = pbiplintRule({
5309
5402
  });
5310
5403
  var filterRules = [HARDCODED_YEAR_IN_FILTER];
5311
5404
 
5405
+ // ../core/src/rules/pbiplint/formatting.ts
5406
+ var DECIMAL_COLUMN_WITHOUT_FORMAT_STRING = pbiplintRule({
5407
+ id: "DECIMAL_COLUMN_WITHOUT_FORMAT_STRING",
5408
+ name: "Visible decimal column with no format string",
5409
+ category: "Formatting",
5410
+ severity: 1,
5411
+ scope: ["Column", "CalculatedColumn", "CalculatedTableColumn"],
5412
+ layer: "model",
5413
+ skipWhenModelUnread: tablesPartlyRead,
5414
+ check: ({ model }) => (model ? allColumns(model) : []).filter(
5415
+ (c) => (dataType(c) === "double" || dataType(c) === "decimal") && !hiddenOrTableHidden(c) && isBlank(c.formatString)
5416
+ ).map(finding.column)
5417
+ });
5418
+ var formattingRules = [DECIMAL_COLUMN_WITHOUT_FORMAT_STRING];
5419
+
5312
5420
  // ../core/src/rules/pbiplint/functions.ts
5313
5421
  var packageOf = (f) => f.annotations.DAXLIB_PackageId;
5314
5422
  function notCalled(model, references) {
@@ -5621,6 +5729,74 @@ var TAB_ORDER_FOLLOWS_LAYOUT = pbiplintRule({
5621
5729
  });
5622
5730
  var tabOrderRules = [TAB_ORDER_FOLLOWS_LAYOUT];
5623
5731
 
5732
+ // ../core/src/rules/pbiplint/translations.ts
5733
+ var listOf3 = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
5734
+ function namesWithoutTranslation(model) {
5735
+ const ownCulture = model.props.culture;
5736
+ const own = typeof ownCulture === "string" ? ownCulture.toLowerCase() : void 0;
5737
+ const cultures = model.cultures.filter(
5738
+ (c) => c.translations !== void 0 && c.name.toLowerCase() !== own
5739
+ );
5740
+ if (cultures.length === 0) return [];
5741
+ const out = [];
5742
+ const report = (f, captioned) => {
5743
+ const missing = cultures.filter((c) => !captioned(c.translations)).map((c) => c.name);
5744
+ if (missing.length === 0) return;
5745
+ const cultureDetail = `no caption in ${listOf3(missing)}`;
5746
+ out.push({ ...f, detail: f.detail ? `${f.detail}, ${cultureDetail}` : cultureDetail });
5747
+ };
5748
+ for (const t of model.tables) {
5749
+ if (t.isHidden) continue;
5750
+ report(finding.table(t), (tr) => tr.tables.includes(t.name));
5751
+ for (const c of t.columns)
5752
+ if (!c.isHidden)
5753
+ report(
5754
+ finding.column(c),
5755
+ (tr) => tr.columns.some((x) => x.table === t.name && x.name === c.name)
5756
+ );
5757
+ for (const m of t.measures)
5758
+ if (!m.isHidden)
5759
+ report(
5760
+ finding.measure(m),
5761
+ (tr) => tr.measures.some((x) => x.table === t.name && x.name === m.name)
5762
+ );
5763
+ for (const h of t.hierarchies) {
5764
+ if (h.isHidden) continue;
5765
+ report(
5766
+ finding.hierarchy(h),
5767
+ (tr) => tr.hierarchies.some((x) => x.table === t.name && x.name === h.name)
5768
+ );
5769
+ for (const l of h.levels)
5770
+ report(
5771
+ finding.level(l),
5772
+ (tr) => tr.levels.some((x) => x.table === t.name && x.hierarchy === h.name && x.name === l.name)
5773
+ );
5774
+ }
5775
+ }
5776
+ return out;
5777
+ }
5778
+ var NAME_WITHOUT_TRANSLATION = pbiplintRule({
5779
+ id: "NAME_WITHOUT_TRANSLATION",
5780
+ name: "Visible name with no translation",
5781
+ category: "Naming Conventions",
5782
+ severity: 1,
5783
+ scope: [
5784
+ "Table",
5785
+ "Column",
5786
+ "CalculatedColumn",
5787
+ "CalculatedTable",
5788
+ "CalculatedTableColumn",
5789
+ "CalculationGroupTable",
5790
+ "Measure",
5791
+ "Hierarchy",
5792
+ "Level"
5793
+ ],
5794
+ layer: "model",
5795
+ skipWhenModelUnread: modelPartlyRead,
5796
+ check: ({ model }) => model ? namesWithoutTranslation(model) : []
5797
+ });
5798
+ var translationRules = [NAME_WITHOUT_TRANSLATION];
5799
+
5624
5800
  // ../core/src/rules/pbiplint/visuals.ts
5625
5801
  var HIDDEN_VISUAL_WITH_FIELDS = pbiplintRule({
5626
5802
  id: "HIDDEN_VISUAL_WITH_FIELDS",
@@ -5745,6 +5921,8 @@ var pbiplintRules = [
5745
5921
  ...periodRules,
5746
5922
  ...filterRules,
5747
5923
  ...functionRules,
5924
+ ...translationRules,
5925
+ ...formattingRules,
5748
5926
  ...actionRules,
5749
5927
  ...tabOrderRules
5750
5928
  ];
@@ -6754,9 +6932,10 @@ Quirks
6754
6932
  - The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.
6755
6933
  - The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.
6756
6934
  - Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.
6935
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
6757
6936
 
6758
6937
  Read more: https://pbiplint.com/rules/add-data-category-for-columns`,
6759
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n summarizeBy: none\n sourceColumn: City\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n dataCategory: City\n summarizeBy: none\n sourceColumn: City\n```\n\n### Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane, open Column tools, and pick the Data category. In the TMDL file, add the property under the column: `dataCategory: City`, `dataCategory: Country`, `dataCategory: Continent`, `dataCategory: Latitude`, or `dataCategory: Longitude`. The property is metadata only, so setting it changes nothing about what the refresh loads or how the column compresses.\n\n### When to ignore it\n\nA column the name test catches that holds no geography is the case to look for first: Country Manager is a person, City Code is an internal code nobody plots, and giving either a data category would be wrong rather than merely unnecessary. A latitude and longitude pair that only ever feeds a distance calculation is in the same position, since nothing binds it to a map. Where the column really is a place name and a report shows it on a map, there is no reason to leave the category off.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ADD_DATA_CATEGORY_FOR_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ADD_DATA_CATEGORY_FOR_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.\n- The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.\n- Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns"
6938
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n summarizeBy: none\n sourceColumn: City\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n dataCategory: City\n summarizeBy: none\n sourceColumn: City\n```\n\n### Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane, open Column tools, and pick the Data category. In the TMDL file, add the property under the column: `dataCategory: City`, `dataCategory: Country`, `dataCategory: Continent`, `dataCategory: Latitude`, or `dataCategory: Longitude`. The property is metadata only, so setting it changes nothing about what the refresh loads or how the column compresses.\n\n### When to ignore it\n\nA column the name test catches that holds no geography is the case to look for first: Country Manager is a person, City Code is an internal code nobody plots, and giving either a data category would be wrong rather than merely unnecessary. A latitude and longitude pair that only ever feeds a distance calculation is in the same position, since nothing binds it to a map. Where the column really is a place name and a report shows it on a map, there is no reason to leave the category off.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ADD_DATA_CATEGORY_FOR_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ADD_DATA_CATEGORY_FOR_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.\n- The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.\n- Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns"
6760
6939
  },
6761
6940
  "AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS": {
6762
6941
  text: `Why it matters
@@ -6962,9 +7141,10 @@ Quirks
6962
7141
  - The type name is compared in lower case, so dataType: Double and dataType: double are both reported.
6963
7142
  - Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.
6964
7143
  - Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.
7144
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
6965
7145
 
6966
7146
  Read more: https://pbiplint.com/rules/avoid-floating-point-data-types`,
6967
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: double\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent. Fixed Decimal Number stores four decimal places exactly, and Whole Number has no fraction to lose. On an imported column, Fixed Decimal Number can be cheaper as well: the engine holds it as a whole number with the four places assumed, so the engine may be more likely to encode the column by value, where a sum works on the stored numbers without looking each one up, and the column may compress better.\n\n### How to fix it\n\nChange the type where the data is loaded. In Power BI Desktop, choose Transform data, select the column, and pick Fixed decimal number or Whole number from Data Type on the Transform tab; on an import table that converts the values before they reach the model, and on a DirectQuery table the step folds into the query the source runs. You can also set the type in Table view or Report view: select the column, then pick the type from Data type on the Column tools tab. In the TMDL file the property is `dataType: decimal` for Fixed Decimal Number and `dataType: int64` for Whole Number. For a calculated column the type follows the expression, so convert it there: `Unit Price = CURRENCY(DIVIDE('Sales'[Amount], 'Sales'[Quantity]))` returns a fixed decimal whatever the division produced on its own. Best of all, fix the type in the view or the table the query reads, so every model that loads the column starts right.\n\n### When to ignore it\n\nA value that genuinely needs more than four decimal places has to stay Double, and the rule has no way to know which those are. Latitude and longitude are the everyday case: four decimal places is about eleven metres, which is fine for a country map and wrong for a site plan, so a geography column is usually left alone. Scientific readings, unit conversion factors, and exchange rates quoted to six places are the same. Values above the Fixed Decimal Number range are the other case, since it tops out at about 922 trillion. Money is never the exception: if the column holds an amount someone will add up and reconcile, the rounding errors the rule warns about are exactly the ones that end up in a support ticket.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_FLOATING_POINT_DATA_TYPES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_FLOATING_POINT_DATA_TYPES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The type name is compared in lower case, so `dataType: Double` and `dataType: double` are both reported.\n- Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.\n- Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types"
7147
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: double\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent. Fixed Decimal Number stores four decimal places exactly, and Whole Number has no fraction to lose. On an imported column, Fixed Decimal Number can be cheaper as well: the engine holds it as a whole number with the four places assumed, so the engine may be more likely to encode the column by value, where a sum works on the stored numbers without looking each one up, and the column may compress better.\n\n### How to fix it\n\nChange the type where the data is loaded. In Power BI Desktop, choose Transform data, select the column, and pick Fixed decimal number or Whole number from Data Type on the Transform tab; on an import table that converts the values before they reach the model, and on a DirectQuery table the step folds into the query the source runs. You can also set the type in Table view or Report view: select the column, then pick the type from Data type on the Column tools tab. In the TMDL file the property is `dataType: decimal` for Fixed Decimal Number and `dataType: int64` for Whole Number. For a calculated column the type follows the expression, so convert it there: `Unit Price = CURRENCY(DIVIDE('Sales'[Amount], 'Sales'[Quantity]))` returns a fixed decimal whatever the division produced on its own. Best of all, fix the type in the view or the table the query reads, so every model that loads the column starts right.\n\n### When to ignore it\n\nA value that genuinely needs more than four decimal places has to stay Double, and the rule has no way to know which those are. Latitude and longitude are the everyday case: four decimal places is about eleven metres, which is fine for a country map and wrong for a site plan, so a geography column is usually left alone. Scientific readings, unit conversion factors, and exchange rates quoted to six places are the same. Values above the Fixed Decimal Number range are the other case, since it tops out at about 922 trillion. Money is never the exception: if the column holds an amount someone will add up and reconcile, the rounding errors the rule warns about are exactly the ones that end up in a support ticket.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_FLOATING_POINT_DATA_TYPES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_FLOATING_POINT_DATA_TYPES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The type name is compared in lower case, so `dataType: Double` and `dataType: double` are both reported.\n- Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.\n- Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types"
6968
7148
  },
6969
7149
  AVOID_INVALID_DESCRIPTION_CHARACTERS: {
6970
7150
  text: `Example
@@ -8193,28 +8373,30 @@ table Date
8193
8373
 
8194
8374
  Why it matters
8195
8375
 
8196
- Marking the date table tells the engine which column is the calendar key, and every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.
8376
+ Marking the date table tells the engine which column is the calendar key, and classic time intelligence relies on it (Classic time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)). Where the date table is related to the other tables on a column that is not a date, such as a whole-number date key like 20241231, Microsoft asks for the marking for the time intelligence functions to work: "you need to set your own date table in order use the time intelligence capabilities" (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.
8197
8377
 
8198
8378
  How to fix it
8199
8379
 
8200
- In Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is dataCategory: Time on the table and isKey on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the calendar rather than deriving it from a fact table. Where the table is not a calendar at all and only the name caught it, rename it or ignore the finding on it.
8380
+ In Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is dataCategory: Time on the table and isKey on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the date table rather than deriving it from a fact table. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, defining a calendar on the table under Calendar options in Table tools clears the finding instead, since its functions need the marking only in the cases Microsoft lists (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)). Where the table is not a date table at all and only the name caught it, rename it or ignore the finding on it.
8201
8381
 
8202
8382
  When to ignore it
8203
8383
 
8204
- The name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a calendar is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A calendar at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked and the finding stays for as long as the table exists. What is not a legitimate exception is the model's real day-grain calendar left unmarked because time intelligence appears to work in a quick test; it fails quietly at the edges of the range.
8384
+ The name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a date table is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A date table at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked; a calendar defined on it, which needs no row for every day, clears the finding, and without one the finding stays for as long as the table exists. What is not a legitimate exception is the model's real day-grain date table left unmarked where Microsoft asks for the marking: when the model uses the classic time intelligence functions, relates the date table to other tables on a column that is not a date, or is read with advanced date filters in Excel PivotTables (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
8205
8385
 
8206
8386
  To ignore this rule on one object, add annotation pbiplint.ignore = DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "off" under rules in pbiplint.config.json.
8207
8387
 
8208
8388
  Quirks
8209
8389
 
8210
8390
  - The table name is upper-cased before the test and the match is a substring, so Date, date, and DATE_DIM all count, and so do Updates and Candidates.
8211
- - The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked.
8212
- - The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.
8213
- - Calculated tables are in scope and calculation groups are not, so a calendar built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.
8214
- - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
8391
+ - The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked, so a table without a calendar is still reported.
8392
+ - Without a calendar, the key has to be a DateTime column, and a table keyed on an integer date key is reported however it is categorized.
8393
+ - A table marked as a date table whose key column has no dataType line, as Power BI Desktop saves a CALENDAR table's Date column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" (Mark your date table as the appropriate data type (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.
8394
+ - Calculated tables are in scope and calculation groups are not, so a date table built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.
8395
+ - A table that defines a calendar is not reported, since calendar-based time intelligence works without the table being marked as a date table. The source rule does not read calendars, and Tabular Editor 3 has no version of this rule. The cases where Microsoft still asks for the marking, among them a relationship to the table on a column that is not DateTime, such as an integer date key, are not read here, so a table that defines a calendar is left out even in those cases (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
8396
+ - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, define a calendar on it, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
8215
8397
 
8216
8398
  Read more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table`,
8217
- markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.\n\n### How to fix it\n\nIn Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is `dataCategory: Time` on the table and `isKey` on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the calendar rather than deriving it from a fact table. Where the table is not a calendar at all and only the name caught it, rename it or ignore the finding on it.\n\n### When to ignore it\n\nThe name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a calendar is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A calendar at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked and the finding stays for as long as the table exists. What is not a legitimate exception is the model\'s real day-grain calendar left unmarked because time intelligence appears to work in a quick test; it fails quietly at the edges of the range.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The table name is upper-cased before the test and the match is a substring, so `Date`, `date`, and `DATE_DIM` all count, and so do Updates and Candidates.\n- The data category comparison is exact and case-sensitive. A file that says `dataCategory: time` does not count as marked.\n- The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.\n- Calculated tables are in scope and calculation groups are not, so a calendar built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.\n- The rule also needs every part of a table\'s declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file\'s own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table'
8399
+ markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and classic time intelligence relies on it ([Classic time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)). Where the date table is related to the other tables on a column that is not a date, such as a whole-number date key like 20241231, Microsoft asks for the marking for the time intelligence functions to work: "you need to set your own date table in order use the time intelligence capabilities" ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.\n\n### How to fix it\n\nIn Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is `dataCategory: Time` on the table and `isKey` on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the date table rather than deriving it from a fact table. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, defining a calendar on the table under Calendar options in Table tools clears the finding instead, since its functions need the marking only in the cases Microsoft lists ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)). Where the table is not a date table at all and only the name caught it, rename it or ignore the finding on it.\n\n### When to ignore it\n\nThe name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a date table is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A date table at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked; a calendar defined on it, which needs no row for every day, clears the finding, and without one the finding stays for as long as the table exists. What is not a legitimate exception is the model\'s real day-grain date table left unmarked where Microsoft asks for the marking: when the model uses the classic time intelligence functions, relates the date table to other tables on a column that is not a date, or is read with advanced date filters in Excel PivotTables ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The table name is upper-cased before the test and the match is a substring, so `Date`, `date`, and `DATE_DIM` all count, and so do Updates and Candidates.\n- The data category comparison is exact and case-sensitive. A file that says `dataCategory: time` does not count as marked, so a table without a calendar is still reported.\n- Without a calendar, the key has to be a DateTime column, and a table keyed on an integer date key is reported however it is categorized.\n- A table marked as a date table whose key column has no `dataType` line, as Power BI Desktop saves a `CALENDAR` table\'s `Date` column, counts as having its date key, since pbiplint does not know the column\'s type and a marked date table\'s key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" ([Mark your date table as the appropriate data type](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.\n- Calculated tables are in scope and calculation groups are not, so a date table built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.\n- A table that defines a calendar is not reported, since calendar-based time intelligence works without the table being marked as a date table. The source rule does not read calendars, and Tabular Editor 3 has no version of this rule. The cases where Microsoft still asks for the marking, among them a relationship to the table on a column that is not DateTime, such as an integer date key, are not read here, so a table that defines a calendar is left out even in those cases ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n- The rule also needs every part of a table\'s declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, define a calendar on it, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file\'s own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table'
8218
8400
  },
8219
8401
  DATECOLUMN_FORMATSTRING: {
8220
8402
  text: `Example
@@ -8270,9 +8452,10 @@ Quirks
8270
8452
  - Only the exact string mm/dd/yyyy passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including dd/mm/yyyy and yyyy-mm-dd.
8271
8453
  - Hidden columns and columns in hidden tables are in scope.
8272
8454
  - Only DateTime columns are read. A date held as text or as an integer date key is not reported here.
8455
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
8273
8456
 
8274
8457
  Read more: https://pbiplint.com/rules/datecolumn-formatstring`,
8275
- markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nA date column with no format string is shown however the viewer\'s locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: mm/dd/yyyy` under the column. The format string changes only how the value is rendered; the stored value and the data type are untouched, so nothing downstream of the model has to change with it.\n\n### When to ignore it\n\nA model whose house convention is a different date order has nothing to gain here: every date column in it fires, and rewriting them all to the US short date is a worse outcome than the finding. Pick the format your readers expect, set it consistently, and treat the rule as house style you have already settled. The other case is a DateTime column that is not a date a reader ever sees, such as a hidden load timestamp caught by the name test, where a format string changes nothing on screen.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATECOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATECOLUMN_FORMATSTRING": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any column containing the letters date is checked, including Update Time and Candidate Start.\n- Only the exact string `mm/dd/yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including `dd/mm/yyyy` and `yyyy-mm-dd`.\n- Hidden columns and columns in hidden tables are in scope.\n- Only DateTime columns are read. A date held as text or as an integer date key is not reported here.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring'
8458
+ markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nA date column with no format string is shown however the viewer\'s locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: mm/dd/yyyy` under the column. The format string changes only how the value is rendered; the stored value and the data type are untouched, so nothing downstream of the model has to change with it.\n\n### When to ignore it\n\nA model whose house convention is a different date order has nothing to gain here: every date column in it fires, and rewriting them all to the US short date is a worse outcome than the finding. Pick the format your readers expect, set it consistently, and treat the rule as house style you have already settled. The other case is a DateTime column that is not a date a reader ever sees, such as a hidden load timestamp caught by the name test, where a format string changes nothing on screen.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATECOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATECOLUMN_FORMATSTRING": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any column containing the letters date is checked, including Update Time and Candidate Start.\n- Only the exact string `mm/dd/yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including `dd/mm/yyyy` and `yyyy-mm-dd`.\n- Hidden columns and columns in hidden tables are in scope.\n- Only DateTime columns are read. A date held as text or as an integer date key is not reported here.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column\'s DAX.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring'
8276
8459
  },
8277
8460
  DAX_COLUMNS_FULLY_QUALIFIED: {
8278
8461
  text: `Example
@@ -8387,6 +8570,50 @@ Quirks
8387
8570
  Read more: https://pbiplint.com/rules/dax-measures-unqualified`,
8388
8571
  markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's `\"'Sales'[Total Sales]\"`, is not reported.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so `'Sales'[Total Sales]` written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- The reference has to resolve. `'Sales'[Total Sales]` written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.\n- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.\n- The table name may be written bare or in single quotes; both forms are matched.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
8389
8572
  },
8573
+ DECIMAL_COLUMN_WITHOUT_FORMAT_STRING: {
8574
+ text: `Example
8575
+
8576
+ Fires the rule
8577
+
8578
+ table Sales
8579
+ column 'Discount Rate'
8580
+ dataType: double
8581
+ summarizeBy: none
8582
+ sourceColumn: Discount Rate
8583
+
8584
+ After the fix
8585
+
8586
+ table Sales
8587
+ column 'Discount Rate'
8588
+ dataType: double
8589
+ formatString: 0.0%
8590
+ summarizeBy: none
8591
+ sourceColumn: Discount Rate
8592
+
8593
+ Why it matters
8594
+
8595
+ A format string says what a column's numbers are. Without one, a share, an amount, and a plain measurement look alike, or as Tabular Editor's guidance for its version of this rule puts it, "Users can't tell if values are currency, percentages, or plain numbers" (Provide format string for numeric and date columns (https://docs.tabulareditor.com/en/kb/bpa-format-string-columns.html#why-this-matters)). Left at General, Power BI Desktop shows the column's values as plain numbers: in a table visual, a discount amount reads 20.30 with no currency symbol, and a share of 0.25 reads 0.25, not 25%. A format set on the column in the model applies wherever the column is used, "unless a visual or element level format string overrides it" (Use custom format strings in Power BI Desktop (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings)), so setting it once spares every report author setting it visual by visual.
8596
+
8597
+ How to fix it
8598
+
8599
+ In Power BI Desktop, select the column in the Data pane and set Format under Column tools, or select it in Model view and set Format in the Properties pane (Add a model level format string (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#add-a-model-level-format-string)). In the column's TMDL file, add a formatString: line under the column, such as formatString: 0.0% for a ratio or formatString: #,0.00 for an amount.
8600
+
8601
+ When to ignore it
8602
+
8603
+ When no reader sees the column as a number: a decimal kept for a relationship or read only by measures is better hidden than formatted, which clears the finding too. Coordinates are the other case: a latitude or longitude column is there to place points on a map, not to be read as a number, so leaving it at General is fine. Otherwise a visible decimal column is one a report author can drop into a visual, where it shows with no format of its own.
8604
+
8605
+ To ignore this rule on one object, add annotation pbiplint.ignore = DECIMAL_COLUMN_WITHOUT_FORMAT_STRING under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "DECIMAL_COLUMN_WITHOUT_FORMAT_STRING": "off" under rules in pbiplint.config.json.
8606
+
8607
+ Quirks
8608
+
8609
+ - Whole-number and date columns are left out, since Power BI Desktop gives each of them a format string by default. The source rule, Tabular Editor 3's built-in, reports them as well.
8610
+ - A column whose TMDL has no dataType line is not read, since pbiplint does not know its type. Power BI Desktop leaves the line out of most calculated columns it saves.
8611
+ - A format string of only spaces counts as none, as it does in PROVIDE_FORMAT_STRING_FOR_MEASURES.
8612
+ - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the column's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
8613
+
8614
+ Read more: https://pbiplint.com/rules/decimal-column-without-format-string`,
8615
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Discount Rate'\n dataType: double\n summarizeBy: none\n sourceColumn: Discount Rate\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Discount Rate'\n dataType: double\n formatString: 0.0%\n summarizeBy: none\n sourceColumn: Discount Rate\n```\n\n### Why it matters\n\nA format string says what a column's numbers are. Without one, a share, an amount, and a plain measurement look alike, or as Tabular Editor's guidance for its version of this rule puts it, \"Users can't tell if values are currency, percentages, or plain numbers\" ([Provide format string for numeric and date columns](https://docs.tabulareditor.com/en/kb/bpa-format-string-columns.html#why-this-matters)). Left at General, Power BI Desktop shows the column's values as plain numbers: in a table visual, a discount amount reads `20.30` with no currency symbol, and a share of 0.25 reads `0.25`, not 25%. A format set on the column in the model applies wherever the column is used, \"unless a visual or element level format string overrides it\" ([Use custom format strings in Power BI Desktop](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings)), so setting it once spares every report author setting it visual by visual.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools, or select it in Model view and set Format in the Properties pane ([Add a model level format string](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#add-a-model-level-format-string)). In the column's TMDL file, add a `formatString:` line under the column, such as `formatString: 0.0%` for a ratio or `formatString: #,0.00` for an amount.\n\n### When to ignore it\n\nWhen no reader sees the column as a number: a decimal kept for a relationship or read only by measures is better hidden than formatted, which clears the finding too. Coordinates are the other case: a latitude or longitude column is there to place points on a map, not to be read as a number, so leaving it at General is fine. Otherwise a visible decimal column is one a report author can drop into a visual, where it shows with no format of its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DECIMAL_COLUMN_WITHOUT_FORMAT_STRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DECIMAL_COLUMN_WITHOUT_FORMAT_STRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Whole-number and date columns are left out, since Power BI Desktop gives each of them a format string by default. The source rule, Tabular Editor 3's built-in, reports them as well.\n- A column whose TMDL has no `dataType` line is not read, since pbiplint does not know its type. Power BI Desktop leaves the line out of most calculated columns it saves.\n- A format string of only spaces counts as none, as it does in `PROVIDE_FORMAT_STRING_FOR_MEASURES`.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the column's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/decimal-column-without-format-string"
8616
+ },
8390
8617
  DEFAULT_PAGE_NAME: {
8391
8618
  text: `Example
8392
8619
 
@@ -9217,10 +9444,11 @@ Quirks
9217
9444
  - The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.
9218
9445
  - Hidden columns, and columns in hidden tables, are skipped by both halves.
9219
9446
  - The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.
9447
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
9220
9448
  - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
9221
9449
 
9222
9450
  Read more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings`,
9223
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: int64\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: int64\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: string\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: string\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nThe values have to change, not just the type, so the fix lives upstream of the model. In Power BI Desktop, choose Transform data, select the query, and either add a conditional column that returns Yes or No or use Replace Values on the column itself, then set the column's type to Text in Power Query. Where the query reads a view or a stored procedure, do the same in the select list and the model gets the text column already formed. The Data type box under Column tools changes the type in place on an import model but leaves the values as 0 and 1, so it is not the fix on its own. If a measure counts the flag, keep the numeric column and hide it, which also takes it out of this rule's reach.\n\n### When to ignore it\n\nA column whose type is already boolean is the common false alarm: Power BI shows it as True and False, which reads as well as Yes and No, and the rule reports it only because it tests for text. The name tests catch words that are not flags at all, so a whole-number Issue Count or Isotope Number is noise on the Is side. The case worth acting on is the one the rule was written for: a visible 0 and 1 column that a report author has to decode.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both name tests are case-sensitive. The prefix is the two characters Is, so a whole-number column called Island or Issue Count is reported, and one called isActive is not.\n- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.\n- Hidden columns, and columns in hidden tables, are skipped by both halves.\n- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
9451
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: int64\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: int64\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: string\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: string\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nThe values have to change, not just the type, so the fix lives upstream of the model. In Power BI Desktop, choose Transform data, select the query, and either add a conditional column that returns Yes or No or use Replace Values on the column itself, then set the column's type to Text in Power Query. Where the query reads a view or a stored procedure, do the same in the select list and the model gets the text column already formed. The Data type box under Column tools changes the type in place on an import model but leaves the values as 0 and 1, so it is not the fix on its own. If a measure counts the flag, keep the numeric column and hide it, which also takes it out of this rule's reach.\n\n### When to ignore it\n\nA column whose type is already boolean is the common false alarm: Power BI shows it as True and False, which reads as well as Yes and No, and the rule reports it only because it tests for text. The name tests catch words that are not flags at all, so a whole-number Issue Count or Isotope Number is noise on the Is side. The case worth acting on is the one the rule was written for: a visible 0 and 1 column that a report author has to decode.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both name tests are case-sensitive. The prefix is the two characters Is, so a whole-number column called Island or Issue Count is reported, and one called isActive is not.\n- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.\n- Hidden columns, and columns in hidden tables, are skipped by both halves.\n- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
9224
9452
  },
9225
9453
  HARDCODED_PERIOD_IN_DAX: {
9226
9454
  text: `Example
@@ -9586,9 +9814,10 @@ Quirks
9586
9814
  - The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.
9587
9815
  - Only numeric columns are in scope, so COUNTA('Sales'[Region]) over a text column is not reported.
9588
9816
  - A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.
9817
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
9589
9818
 
9590
9819
  Read more: https://pbiplint.com/rules/hide-fact-table-columns`,
9591
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\n### How to fix it\n\nIn Power BI Desktop, open the model view, select the column, and turn on Is hidden in the Properties pane, or right-click the column in the Data pane of report view and choose Hide. In the TMDL file, add `isHidden` under the column. The column is still loaded, still refreshed, and still available to every measure and relationship; it only leaves the field list.\n\n### When to ignore it\n\nA numeric column readers use as an attribute rather than as a number is the case to keep visible: a Year on a date table, a Unit Price a report author slices by, a Rating people put on an axis. That a measure also aggregates the column somewhere does not make it the wrong field to pick. The rule does not test whether the table is a fact table either, so a dimension attribute that one measure happens to sum is reported on the same terms as a fact column. What to check before hiding is whether a report already binds a visual to the column, because hiding it does not break that visual but does remove the field from the list for whoever edits it next.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HIDE_FACT_TABLE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HIDE_FACT_TABLE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only fully qualified references count, and the quote marks around the table name are optional, so `SUM(Sales[Amount])` matches as well as `SUM('Sales'[Amount])`. A bare `SUM([Amount])` inside a measure on the same table does not.\n- Nothing may come between the column reference and the aggregation's closing parenthesis, so `SUM('Sales'[Amount] * 2)` is not matched. Spaces after the function name and inside the parentheses are allowed, and the function name matches in any letter case.\n- The measure may live on any table in the model, not only on the column's own table.\n- The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.\n- Only numeric columns are in scope, so `COUNTA('Sales'[Region])` over a text column is not reported.\n- A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns"
9820
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\n### How to fix it\n\nIn Power BI Desktop, open the model view, select the column, and turn on Is hidden in the Properties pane, or right-click the column in the Data pane of report view and choose Hide. In the TMDL file, add `isHidden` under the column. The column is still loaded, still refreshed, and still available to every measure and relationship; it only leaves the field list.\n\n### When to ignore it\n\nA numeric column readers use as an attribute rather than as a number is the case to keep visible: a Year on a date table, a Unit Price a report author slices by, a Rating people put on an axis. That a measure also aggregates the column somewhere does not make it the wrong field to pick. The rule does not test whether the table is a fact table either, so a dimension attribute that one measure happens to sum is reported on the same terms as a fact column. What to check before hiding is whether a report already binds a visual to the column, because hiding it does not break that visual but does remove the field from the list for whoever edits it next.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HIDE_FACT_TABLE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HIDE_FACT_TABLE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only fully qualified references count, and the quote marks around the table name are optional, so `SUM(Sales[Amount])` matches as well as `SUM('Sales'[Amount])`. A bare `SUM([Amount])` inside a measure on the same table does not.\n- Nothing may come between the column reference and the aggregation's closing parenthesis, so `SUM('Sales'[Amount] * 2)` is not matched. Spaces after the function name and inside the parentheses are allowed, and the function name matches in any letter case.\n- The measure may live on any table in the model, not only on the column's own table.\n- The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.\n- Only numeric columns are in scope, so `COUNTA('Sales'[Region])` over a text column is not reported.\n- A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns"
9592
9821
  },
9593
9822
  HIDE_FOREIGN_KEYS: {
9594
9823
  text: `Example
@@ -9902,7 +10131,7 @@ Add isAvailableInMdx: false under the column in its table's TMDL file. Power BI
9902
10131
 
9903
10132
  When to ignore it
9904
10133
 
9905
- Size is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible.
10134
+ Size is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible. A table in Direct Lake storage mode is a judgment of its own: Direct Lake loads a column's data when a query needs it, and its refresh copies only metadata, so the refresh time this rule is about is not spent the same way (see Quirks).
9906
10135
 
9907
10136
  To ignore this rule on one object, add annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS": "off" under rules in pbiplint.config.json.
9908
10137
 
@@ -9911,12 +10140,14 @@ Quirks
9911
10140
  - A column with no isAvailableInMdx line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.
9912
10141
  - Visibility is the column's own isHidden or its table's. A visible column in a hidden table is reported.
9913
10142
  - Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in sortByColumn.
10143
+ - A column a calendar names, as a primary, associated, or time-related column, is not reported: the calendar uses the column for time intelligence, and a primary column for sorting. Tabular Editor 3's built-in version of the rule does not report a primary or associated column a calendar names either. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden.
9914
10144
  - Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.
9915
10145
  - Relationships are not read. A hidden foreign key, which is exactly what HIDE_FOREIGN_KEYS asks you to create, is reported here.
10146
+ - Tables in Direct Lake storage mode are read like any other. Tabular Editor 3's built-in version of the rule skips them, and no source says why. What differs is when the work is done: Direct Lake loads into memory only the column data a query needs, and its refresh copies only metadata (Direct Lake overview (https://learn.microsoft.com/fabric/fundamentals/direct-lake-overview)).
9916
10147
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a variation on another table's column that names the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
9917
10148
 
9918
10149
  Read more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns`,
9919
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in its table's TMDL file. Power BI Desktop has no setting for the property and never writes it, but it keeps the value once it is in the file, and it keeps it the same way on an import table and a DirectQuery one. The other way to clear a finding is to decide the column should not be hidden after all: clear Is hidden in the Properties pane, or remove `isHidden` from under the column, and the rule stops reading it, as long as its table is visible too. Where a table has dozens of hidden keys, Tabular Editor's property grid sets the property on every selected column in one edit, which is quicker than the same change repeated down a file.\n\n### When to ignore it\n\nSize is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.\n- Visibility is the column's own `isHidden` or its table's. A visible column in a hidden table is reported.\n- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in `sortByColumn`.\n- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.\n- Relationships are not read. A hidden foreign key, which is exactly what `HIDE_FOREIGN_KEYS` asks you to create, is reported here.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a variation on another table's column that names the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns"
10150
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in its table's TMDL file. Power BI Desktop has no setting for the property and never writes it, but it keeps the value once it is in the file, and it keeps it the same way on an import table and a DirectQuery one. The other way to clear a finding is to decide the column should not be hidden after all: clear Is hidden in the Properties pane, or remove `isHidden` from under the column, and the rule stops reading it, as long as its table is visible too. Where a table has dozens of hidden keys, Tabular Editor's property grid sets the property on every selected column in one edit, which is quicker than the same change repeated down a file.\n\n### When to ignore it\n\nSize is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible. A table in Direct Lake storage mode is a judgment of its own: Direct Lake loads a column's data when a query needs it, and its refresh copies only metadata, so the refresh time this rule is about is not spent the same way (see Quirks).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.\n- Visibility is the column's own `isHidden` or its table's. A visible column in a hidden table is reported.\n- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in `sortByColumn`.\n- A column a calendar names, as a primary, associated, or time-related column, is not reported: the calendar uses the column for time intelligence, and a primary column for sorting. Tabular Editor 3's built-in version of the rule does not report a primary or associated column a calendar names either. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden.\n- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.\n- Relationships are not read. A hidden foreign key, which is exactly what `HIDE_FOREIGN_KEYS` asks you to create, is reported here.\n- Tables in Direct Lake storage mode are read like any other. Tabular Editor 3's built-in version of the rule skips them, and no source says why. What differs is when the work is done: Direct Lake loads into memory only the column data a query needs, and its refresh copies only metadata ([Direct Lake overview](https://learn.microsoft.com/fabric/fundamentals/direct-lake-overview)).\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a variation on another table's column that names the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns"
9920
10151
  },
9921
10152
  LANDING_PAGE_NOT_SET: {
9922
10153
  text: `Example
@@ -10475,28 +10706,30 @@ table Date
10475
10706
 
10476
10707
  Why it matters
10477
10708
 
10478
- Every time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.
10709
+ Classic time intelligence, where DATESYTD and the rest are given a date column, needs that column to run without a gap, and the marked date table is where it comes from (Classic time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)); calendar-based time intelligence, a preview, is given a calendar defined on the date table instead. Without a date table that time intelligence can use, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.
10479
10710
 
10480
10711
  How to fix it
10481
10712
 
10482
- Add a calendar with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a calendar view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31)); New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the calendar from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes dataCategory: Time on the table and isKey on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column calendars stop being built.
10713
+ Add a date table with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a date view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31)); New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the date table from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes dataCategory: Time on the table and isKey on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column date tables stop being built. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, define a calendar on the date table instead, under Calendar options in Table tools, which writes a calendar block under the table in its file (Calendar-based time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#calendar-based-time-intelligence-preview)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)).
10483
10714
 
10484
10715
  When to ignore it
10485
10716
 
10486
- A model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no calendar would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.
10717
+ A model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no date table would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.
10487
10718
 
10488
10719
  To ignore this rule on one object, add annotation pbiplint.ignore = MODEL_SHOULD_HAVE_A_DATE_TABLE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "MODEL_SHOULD_HAVE_A_DATE_TABLE": "off" under rules in pbiplint.config.json.
10489
10720
 
10490
10721
  Quirks
10491
10722
 
10492
- - Both properties are needed on one table: dataCategory: Time and isKey on one of its DateTime columns. A table with only one of them does not satisfy the rule.
10723
+ - A table that defines a calendar satisfies the rule, as it satisfies Tabular Editor 3's built-in version, since calendar-based time intelligence works without a table marked as a date table. The source rule does not read calendars, so Tabular Editor reports a model whose only calendar table is not marked. Microsoft: "You don't need to identify your own date table with the Mark as Date table option if you use the recommended Calendar-based time intelligence in Power BI unless in specific circumstances" (Set and use date tables in Power BI Desktop (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables)). Those circumstances are not read here, so a calendar satisfies the rule even where Microsoft still asks for the marking (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
10724
+ - Without a calendar, both properties are needed on one table: dataCategory: Time and isKey on one of its DateTime columns. A table with only one of them does not satisfy the rule.
10725
+ - A table marked as a date table whose key column has no dataType line, as Power BI Desktop saves a CALENDAR table's Date column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" (Mark your date table as the appropriate data type (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.
10493
10726
  - The data category comparison is exact and case-sensitive, so dataCategory: time leaves the model reported.
10494
10727
  - Nothing else about the table is tested. It is not checked for contiguity, for covering the model's date range, or for being related to anything, so a one-row table marked as a date table clears the finding without helping any measure.
10495
- - Any table can satisfy it, of any kind. A calculated calendar counts the same as a loaded one.
10728
+ - Any table can satisfy it, of any kind. A calculated date table counts the same as a loaded one.
10496
10729
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the date table could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
10497
10730
 
10498
10731
  Read more: https://pbiplint.com/rules/model-should-have-a-date-table`,
10499
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nEvery time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.\n\n### How to fix it\n\nAdd a calendar with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a calendar view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as `Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31))`; New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the calendar from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes `dataCategory: Time` on the table and `isKey` on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column calendars stop being built.\n\n### When to ignore it\n\nA model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no calendar would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MODEL_SHOULD_HAVE_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MODEL_SHOULD_HAVE_A_DATE_TABLE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both properties are needed on one table: `dataCategory: Time` and `isKey` on one of its DateTime columns. A table with only one of them does not satisfy the rule.\n- The data category comparison is exact and case-sensitive, so `dataCategory: time` leaves the model reported.\n- Nothing else about the table is tested. It is not checked for contiguity, for covering the model's date range, or for being related to anything, so a one-row table marked as a date table clears the finding without helping any measure.\n- Any table can satisfy it, of any kind. A calculated calendar counts the same as a loaded one.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the date table could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/model-should-have-a-date-table"
10732
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nClassic time intelligence, where DATESYTD and the rest are given a date column, needs that column to run without a gap, and the marked date table is where it comes from ([Classic time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)); calendar-based time intelligence, a preview, is given a calendar defined on the date table instead. Without a date table that time intelligence can use, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.\n\n### How to fix it\n\nAdd a date table with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a date view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as `Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31))`; New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the date table from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes `dataCategory: Time` on the table and `isKey` on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column date tables stop being built. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, define a calendar on the date table instead, under Calendar options in Table tools, which writes a `calendar` block under the table in its file ([Calendar-based time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#calendar-based-time-intelligence-preview)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)).\n\n### When to ignore it\n\nA model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no date table would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MODEL_SHOULD_HAVE_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MODEL_SHOULD_HAVE_A_DATE_TABLE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A table that defines a calendar satisfies the rule, as it satisfies Tabular Editor 3's built-in version, since calendar-based time intelligence works without a table marked as a date table. The source rule does not read calendars, so Tabular Editor reports a model whose only calendar table is not marked. Microsoft: \"You don't need to identify your own date table with the Mark as Date table option if you use the recommended Calendar-based time intelligence in Power BI unless in specific circumstances\" ([Set and use date tables in Power BI Desktop](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables)). Those circumstances are not read here, so a calendar satisfies the rule even where Microsoft still asks for the marking ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n- Without a calendar, both properties are needed on one table: `dataCategory: Time` and `isKey` on one of its DateTime columns. A table with only one of them does not satisfy the rule.\n- A table marked as a date table whose key column has no `dataType` line, as Power BI Desktop saves a `CALENDAR` table's `Date` column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: \"you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date\" ([Mark your date table as the appropriate data type](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.\n- The data category comparison is exact and case-sensitive, so `dataCategory: time` leaves the model reported.\n- Nothing else about the table is tested. It is not checked for contiguity, for covering the model's date range, or for being related to anything, so a one-row table marked as a date table clears the finding without helping any measure.\n- Any table can satisfy it, of any kind. A calculated date table counts the same as a loaded one.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the date table could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/model-should-have-a-date-table"
10500
10733
  },
10501
10734
  MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: {
10502
10735
  text: `Example
@@ -10644,9 +10877,10 @@ Quirks
10644
10877
  - Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.
10645
10878
  - Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.
10646
10879
  - Hidden columns, and columns in hidden tables, are in scope.
10880
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
10647
10881
 
10648
10882
  Read more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted`,
10649
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sortByColumn: 'Month Number'\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n### Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\n### How to fix it\n\nAdd a month number column to the table, hide it, then in Power BI Desktop select the month name column and pick the number under Sort by column in Column tools. In the TMDL file the property is `sortByColumn: 'Month Number'` under the month name column. Each month name has to map to exactly one number, so a name like January that appears in several years needs a number column at the same grain, 1 through 12, not a year-month key; Power BI rejects the sort otherwise. Where the axis runs across years, sort a Month Year column by a year-month number instead and leave the plain month name for the within-year views.\n\n### When to ignore it\n\nA text column the name test catches that is not a list of month names is noise: a Monthly Target note, a Month Comment, anything where alphabetical order is as good as any other. A month column whose values already sort correctly is the other case, such as one holding 2026-01 and 2026-02, where a sort-by column would only add a second column to maintain. Where the values are month names, there is no reason to leave the sort alphabetical.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTH_(AS_A_STRING)_MUST_BE_SORTED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTH_(AS_A_STRING)_MUST_BE_SORTED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring of the upper-cased name, so Month Name is reported and so is a text column called Monthly Target.\n- The exclusion is the substring MONTHS, not the word, so Months Elapsed passes and so do MonthStart and MonthSort, whose upper-cased names contain MONTHS. Written with a space, Month Start is reported.\n- Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.\n- Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.\n- Hidden columns, and columns in hidden tables, are in scope.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted"
10883
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sortByColumn: 'Month Number'\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n### Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\n### How to fix it\n\nAdd a month number column to the table, hide it, then in Power BI Desktop select the month name column and pick the number under Sort by column in Column tools. In the TMDL file the property is `sortByColumn: 'Month Number'` under the month name column. Each month name has to map to exactly one number, so a name like January that appears in several years needs a number column at the same grain, 1 through 12, not a year-month key; Power BI rejects the sort otherwise. Where the axis runs across years, sort a Month Year column by a year-month number instead and leave the plain month name for the within-year views.\n\n### When to ignore it\n\nA text column the name test catches that is not a list of month names is noise: a Monthly Target note, a Month Comment, anything where alphabetical order is as good as any other. A month column whose values already sort correctly is the other case, such as one holding 2026-01 and 2026-02, where a sort-by column would only add a second column to maintain. Where the values are month names, there is no reason to leave the sort alphabetical.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTH_(AS_A_STRING)_MUST_BE_SORTED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTH_(AS_A_STRING)_MUST_BE_SORTED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring of the upper-cased name, so Month Name is reported and so is a text column called Monthly Target.\n- The exclusion is the substring MONTHS, not the word, so Months Elapsed passes and so do MonthStart and MonthSort, whose upper-cased names contain MONTHS. Written with a space, Month Start is reported.\n- Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.\n- Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.\n- Hidden columns, and columns in hidden tables, are in scope.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted"
10650
10884
  },
10651
10885
  MONTHCOLUMN_FORMATSTRING: {
10652
10886
  text: `Example
@@ -10702,9 +10936,72 @@ Quirks
10702
10936
  - Only the exact string MMMM yyyy passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.
10703
10937
  - Only DateTime columns are read. A month held as text or as a whole number is not reported here.
10704
10938
  - Hidden columns, and columns in hidden tables, are in scope.
10939
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
10705
10940
 
10706
10941
  Read more: https://pbiplint.com/rules/monthcolumn-formatstring`,
10707
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n### Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The `MMMM yyyy` format shows the month and year, and setting it on the column fixes every visual at once.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: MMMM yyyy` under the column. The column keeps its DateTime type and its value, so it still sorts chronologically and still works in a relationship to the date table; only what a visual prints changes.\n\n### When to ignore it\n\nA DateTime column with month in its name that holds a full date, not a month start, is the case to leave alone: formatting Month End Date as `MMMM yyyy` throws away the day, which is the part a reader needs. House style is the other case, where a model already writes its month labels as `MMM yyyy` and changing them would leave two conventions in the same report. A hidden month column shows nothing to anyone, so its format string is not worth a change on its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTHCOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTHCOLUMN_FORMATSTRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any DateTime column with month in its name is checked, including Month End Date and a Bimonthly Cutoff column.\n- Only the exact string `MMMM yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.\n- Only DateTime columns are read. A month held as text or as a whole number is not reported here.\n- Hidden columns, and columns in hidden tables, are in scope.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring"
10942
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n### Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The `MMMM yyyy` format shows the month and year, and setting it on the column fixes every visual at once.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: MMMM yyyy` under the column. The column keeps its DateTime type and its value, so it still sorts chronologically and still works in a relationship to the date table; only what a visual prints changes.\n\n### When to ignore it\n\nA DateTime column with month in its name that holds a full date, not a month start, is the case to leave alone: formatting Month End Date as `MMMM yyyy` throws away the day, which is the part a reader needs. House style is the other case, where a model already writes its month labels as `MMM yyyy` and changing them would leave two conventions in the same report. A hidden month column shows nothing to anyone, so its format string is not worth a change on its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTHCOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTHCOLUMN_FORMATSTRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any DateTime column with month in its name is checked, including Month End Date and a Bimonthly Cutoff column.\n- Only the exact string `MMMM yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.\n- Only DateTime columns are read. A month held as text or as a whole number is not reported here.\n- Hidden columns, and columns in hidden tables, are in scope.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring"
10943
+ },
10944
+ NAME_WITHOUT_TRANSLATION: {
10945
+ text: `Example
10946
+
10947
+ Fires the rule
10948
+
10949
+ model Model
10950
+ culture: en-US
10951
+
10952
+ table Sales
10953
+ measure Revenue = 1
10954
+ formatString: #,0
10955
+
10956
+ cultureInfo fr-FR
10957
+ translations
10958
+ model Model
10959
+ table Sales
10960
+ caption: Ventes
10961
+
10962
+ After the fix
10963
+
10964
+ model Model
10965
+ culture: en-US
10966
+
10967
+ table Sales
10968
+ measure Revenue = 1
10969
+ formatString: #,0
10970
+
10971
+ cultureInfo fr-FR
10972
+ translations
10973
+ model Model
10974
+ table Sales
10975
+ caption: Ventes
10976
+ measure Revenue
10977
+ caption: Chiffre d'affaires
10978
+
10979
+ Why it matters
10980
+
10981
+ A caption is the name a reader sees for an object when a report is shown in that culture's language: Microsoft describes a translated caption as an alternative name that appears "when the report is rendered in a different language" (Power BI support for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). A model that carries translations for a language is meant to be read in it, so every visible name its culture leaves out is one its readers do not see in their language, beside the ones they do.
10982
+
10983
+ How to fix it
10984
+
10985
+ Give the object a caption in the culture's translations block. Power BI Desktop has no editor for translations, and Microsoft gives its TMDL view as the route for metadata that lacks a graphical interface, "such as translations" (Common use cases for TMDL view (https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). In TMDL view, type createOrReplace on the first line of an empty tab, paste the whole cultureInfo block from definition/cultures/<culture>.tmdl beneath it, and indent the pasted lines one level, so the block sits under the command. Then give each object with no caption an entry in its place, with a caption: line one level beneath the entry, as the fixed example adds measure Revenue under table Sales: a table's entry goes under the model entry, a column's, measure's, or hierarchy's under its table's entry, and a level's under its hierarchy's entry. Where the block already has the object's entry, as it has a table's once any of the table's objects is captioned, add only the caption: line. Select Apply. Script the whole block: createOrReplace "Creates or replaces the specified semantic model objects and all the descendants" (CreateOrReplace command (https://learn.microsoft.com/analysis-services/tmdl/tmdl-scripts#createorreplace-command)), so a script of part of it drops the captions it leaves out. Or edit the culture's file while Desktop is closed. For many objects at once, Microsoft's Translations Builder, an optional tool, can fill in captions, machine translations included (Create multiple-language reports with Translations Builder (https://learn.microsoft.com/power-bi/guidance/translation-builder)).
10986
+
10987
+ When to ignore it
10988
+
10989
+ When the culture's names are not meant for readers yet, such as a translation still in progress, or a culture kept for another purpose that happens to carry a translations block. Otherwise a visible object with no caption is one a reader in that language sees untranslated. A key or helper column no reader needs is better hidden than translated, which clears its finding too.
10990
+
10991
+ To ignore this rule on one object, add annotation pbiplint.ignore = NAME_WITHOUT_TRANSLATION under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "NAME_WITHOUT_TRANSLATION": "off" under rules in pbiplint.config.json.
10992
+
10993
+ Quirks
10994
+
10995
+ - A calculation group table is read like any other table, so a visible one with no caption is reported. The source rules do not read calculation group tables.
10996
+ - An object counts as visible only when it and its table are not hidden, as pbiplint reads visibility elsewhere, so a hidden table, and a measure in one, are not reported. The source rules report both.
10997
+ - A culture with no translations block is not read, so the culture file Power BI Desktop writes for the model's own language, which holds linguistic metadata only, never produces a finding. Tabular Editor's rules read every culture other than the model's own.
10998
+ - The model's own culture is left out, as Microsoft advises: "You don't need to supply metadata translations for the default language of the semantic model" (Organize project for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-locale#organize-project-for-metadata-translation)). The community rules this rule follows leave it out too; Tabular Editor 3's built-in translation rules read it, so they report nearly every visible object in a model Desktop saved. With no culture: line in model.tmdl, every culture with a translations block is read.
10999
+ - A caption equal to the object's name counts, as it does in Tabular Editor's rules, so a name that reads the same in both languages still needs its caption line. A caption of only spaces does not count.
11000
+ - Descriptions and display folders are not read: Microsoft says they need translating only for report authors who work in the Power BI service (Power BI support for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). The model's name and perspective names are not read either; they are not among the object types Microsoft lists as translatable (Metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#metadata-translation)).
11001
+ - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a caption, or the line that hides an object, could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
11002
+
11003
+ Read more: https://pbiplint.com/rules/name-without-translation`,
11004
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\nmodel Model\n culture: en-US\n\ntable Sales\n measure Revenue = 1\n formatString: #,0\n\ncultureInfo fr-FR\n translations\n model Model\n table Sales\n caption: Ventes\n```\n\n**After the fix**\n\n```tmdl\nmodel Model\n culture: en-US\n\ntable Sales\n measure Revenue = 1\n formatString: #,0\n\ncultureInfo fr-FR\n translations\n model Model\n table Sales\n caption: Ventes\n measure Revenue\n caption: Chiffre d'affaires\n```\n\n### Why it matters\n\nA caption is the name a reader sees for an object when a report is shown in that culture's language: Microsoft describes a translated caption as an alternative name that appears \"when the report is rendered in a different language\" ([Power BI support for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). A model that carries translations for a language is meant to be read in it, so every visible name its culture leaves out is one its readers do not see in their language, beside the ones they do.\n\n### How to fix it\n\nGive the object a `caption` in the culture's `translations` block. Power BI Desktop has no editor for translations, and Microsoft gives its TMDL view as the route for metadata that lacks a graphical interface, \"such as translations\" ([Common use cases for TMDL view](https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). In TMDL view, type `createOrReplace` on the first line of an empty tab, paste the whole `cultureInfo` block from `definition/cultures/<culture>.tmdl` beneath it, and indent the pasted lines one level, so the block sits under the command. Then give each object with no caption an entry in its place, with a `caption:` line one level beneath the entry, as the fixed example adds `measure Revenue` under `table Sales`: a table's entry goes under the `model` entry, a column's, measure's, or hierarchy's under its table's entry, and a level's under its hierarchy's entry. Where the block already has the object's entry, as it has a table's once any of the table's objects is captioned, add only the `caption:` line. Select Apply. Script the whole block: `createOrReplace` \"Creates or replaces the specified semantic model objects and all the descendants\" ([CreateOrReplace command](https://learn.microsoft.com/analysis-services/tmdl/tmdl-scripts#createorreplace-command)), so a script of part of it drops the captions it leaves out. Or edit the culture's file while Desktop is closed. For many objects at once, Microsoft's Translations Builder, an optional tool, can fill in captions, machine translations included ([Create multiple-language reports with Translations Builder](https://learn.microsoft.com/power-bi/guidance/translation-builder)).\n\n### When to ignore it\n\nWhen the culture's names are not meant for readers yet, such as a translation still in progress, or a culture kept for another purpose that happens to carry a `translations` block. Otherwise a visible object with no caption is one a reader in that language sees untranslated. A key or helper column no reader needs is better hidden than translated, which clears its finding too.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NAME_WITHOUT_TRANSLATION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"NAME_WITHOUT_TRANSLATION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A calculation group table is read like any other table, so a visible one with no caption is reported. The source rules do not read calculation group tables.\n- An object counts as visible only when it and its table are not hidden, as pbiplint reads visibility elsewhere, so a hidden table, and a measure in one, are not reported. The source rules report both.\n- A culture with no `translations` block is not read, so the culture file Power BI Desktop writes for the model's own language, which holds linguistic metadata only, never produces a finding. Tabular Editor's rules read every culture other than the model's own.\n- The model's own culture is left out, as Microsoft advises: \"You don't need to supply metadata translations for the default language of the semantic model\" ([Organize project for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-locale#organize-project-for-metadata-translation)). The community rules this rule follows leave it out too; Tabular Editor 3's built-in translation rules read it, so they report nearly every visible object in a model Desktop saved. With no `culture:` line in `model.tmdl`, every culture with a `translations` block is read.\n- A caption equal to the object's name counts, as it does in Tabular Editor's rules, so a name that reads the same in both languages still needs its caption line. A caption of only spaces does not count.\n- Descriptions and display folders are not read: Microsoft says they need translating only for report authors who work in the Power BI service ([Power BI support for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). The model's name and perspective names are not read either; they are not among the object types Microsoft lists as translatable ([Metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#metadata-translation)).\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a caption, or the line that hides an object, could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/name-without-translation"
10708
11005
  },
10709
11006
  NOT_REACHED_FROM_REPORT: {
10710
11007
  text: `Example
@@ -10785,7 +11082,7 @@ To ignore this rule on one object, add annotation pbiplint.ignore = NOT_REACHED_
10785
11082
  Quirks
10786
11083
 
10787
11084
  - The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.
10788
- - Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
11085
+ - Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, the columns a calendar names, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A calendar likewise needs the columns it names for time intelligence. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
10789
11086
  - Calculated tables whose names start with LocalDateTable_ or DateTableTemplate_, which pbiplint reads as Power BI Desktop's auto date/time tables the way REMOVE_AUTO-DATE_TABLE does, are left out of the findings, reached or not. So is a composite model's copy of one: a LocalDateTable_ table whose entity partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with showAsVariationsOnly, so it is shown only through a date column's hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and REMOVE_AUTO-DATE_TABLE reports them; the table a copy reads is calculated in the model it extends, and that model's own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.
10790
11087
  - UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model's own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.
10791
11088
  - DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.
@@ -10797,7 +11094,7 @@ Quirks
10797
11094
  - The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt table, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, a model file could not be fully read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A /// description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.
10798
11095
 
10799
11096
  Read more: https://pbiplint.com/rules/not-reached-from-report`,
10800
- markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( \'Sales\'[Region] ), "Share", [Total] ), [Share] )` does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function\'s whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
11097
+ markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, the columns a calendar names, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A calendar likewise needs the columns it names for time intelligence. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( \'Sales\'[Region] ), "Share", [Total] ), [Share] )` does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function\'s whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
10801
11098
  },
10802
11099
  NUMERIC_COLUMN_SUMMARIZE_BY: {
10803
11100
  text: `Example
@@ -10837,10 +11134,11 @@ Quirks
10837
11134
  - The property value is compared without regard to letter case, so summarizeBy: None passes as well as summarizeBy: none.
10838
11135
  - Hidden columns, and columns in hidden tables, are skipped.
10839
11136
  - Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.
11137
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
10840
11138
  - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
10841
11139
 
10842
11140
  Read more: https://pbiplint.com/rules/numeric-column-summarize-by`,
10843
- markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Summarization to Don\'t summarize under Column tools. In the TMDL file the property is `summarizeBy: none` under the column. Where the column really is a number reports need totals of, add an explicit measure for it, because the point of the change is that the aggregation becomes something the model defines rather than something a visual guesses.\n\n### When to ignore it\n\nAn additive column with no measure behind it is the case to weigh. On a small planning or budget table that a handful of people build their own matrices from, the implicit sum is the feature, and taking it away without writing the measures first makes the model harder to use, not safer. Check which reports drag the column in before you change it. A year, a key, a price, or a rate is never that case: summing any of them produces a number with no meaning, and those are the findings to act on first.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NUMERIC_COLUMN_SUMMARIZE_BY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NUMERIC_COLUMN_SUMMARIZE_BY": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `summarizeBy` property is treated as Default, which is not None, so it is reported. That is where most findings come from.\n- The property value is compared without regard to letter case, so `summarizeBy: None` passes as well as `summarizeBy: none`.\n- Hidden columns, and columns in hidden tables, are skipped.\n- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.\n- The rule also needs every part of a table\'s declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file\'s own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by'
11141
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Summarization to Don't summarize under Column tools. In the TMDL file the property is `summarizeBy: none` under the column. Where the column really is a number reports need totals of, add an explicit measure for it, because the point of the change is that the aggregation becomes something the model defines rather than something a visual guesses.\n\n### When to ignore it\n\nAn additive column with no measure behind it is the case to weigh. On a small planning or budget table that a handful of people build their own matrices from, the implicit sum is the feature, and taking it away without writing the measures first makes the model harder to use, not safer. Check which reports drag the column in before you change it. A year, a key, a price, or a rate is never that case: summing any of them produces a number with no meaning, and those are the findings to act on first.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NUMERIC_COLUMN_SUMMARIZE_BY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"NUMERIC_COLUMN_SUMMARIZE_BY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `summarizeBy` property is treated as Default, which is not None, so it is reported. That is where most findings come from.\n- The property value is compared without regard to letter case, so `summarizeBy: None` passes as well as `summarizeBy: none`.\n- Hidden columns, and columns in hidden tables, are skipped.\n- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by"
10844
11142
  },
10845
11143
  OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
10846
11144
  text: `Example
@@ -11253,7 +11551,7 @@ An empty perspective still shows up in clients that offer perspectives, such as
11253
11551
 
11254
11552
  How to fix it
11255
11553
 
11256
- Power BI Desktop has no perspective editor, so the fix is in the file. A perspective is a perspective block of its own, and the objects it shows are perspectiveTable entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. Add the tables the perspective should show, or delete its file from the perspectives folder and drop the matching ref perspective line from model.tmdl. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.
11554
+ Power BI Desktop has no graphical editor for perspectives, and its TMDL view is the route Microsoft gives (Common use cases for TMDL view (https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). A perspective is a perspective block of its own, and the objects it shows are perspectiveTable entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. In TMDL view, write a createOrReplace script of the whole perspective with the tables it should show, and select Apply. To remove the perspective instead, close Desktop, delete its file from the perspectives folder, and drop the matching ref perspective line from model.tmdl. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.
11257
11555
 
11258
11556
  When to ignore it
11259
11557
 
@@ -11264,10 +11562,9 @@ To ignore this rule on one object, add annotation pbiplint.ignore = PERSPECTIVES
11264
11562
  Quirks
11265
11563
 
11266
11564
  - The rule counts the perspectiveTable entries the perspective carries, not the objects they resolve to. A perspective that lists a table which was deleted is not empty and is not reported.
11267
- - Power BI Desktop never writes perspectives, so this rule fires only on models built or edited somewhere else.
11268
11565
 
11269
11566
  Read more: https://pbiplint.com/rules/perspectives-with-no-objects`,
11270
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\nperspective 'Sales View'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\nperspective 'Sales View'\n\n perspectiveTable Sales\n\n perspectiveMeasure 'Total Sales'\n```\n\n### Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\n### How to fix it\n\nPower BI Desktop has no perspective editor, so the fix is in the file. A perspective is a `perspective` block of its own, and the objects it shows are `perspectiveTable` entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. Add the tables the perspective should show, or delete its file from the `perspectives` folder and drop the matching `ref perspective` line from `model.tmdl`. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.\n\n### When to ignore it\n\nThere is no case for it. A perspective you have created and not yet filled is the one you want reported, because nothing else will tell you it is still empty, and an empty perspective in a published model offers a report author a view of the model with no fields in it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PERSPECTIVES_WITH_NO_OBJECTS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PERSPECTIVES_WITH_NO_OBJECTS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule counts the `perspectiveTable` entries the perspective carries, not the objects they resolve to. A perspective that lists a table which was deleted is not empty and is not reported.\n- Power BI Desktop never writes perspectives, so this rule fires only on models built or edited somewhere else.\n\nRead more: https://pbiplint.com/rules/perspectives-with-no-objects"
11567
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\nperspective 'Sales View'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\nperspective 'Sales View'\n\n perspectiveTable Sales\n\n perspectiveMeasure 'Total Sales'\n```\n\n### Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\n### How to fix it\n\nPower BI Desktop has no graphical editor for perspectives, and its TMDL view is the route Microsoft gives ([Common use cases for TMDL view](https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). A perspective is a `perspective` block of its own, and the objects it shows are `perspectiveTable` entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. In TMDL view, write a `createOrReplace` script of the whole perspective with the tables it should show, and select Apply. To remove the perspective instead, close Desktop, delete its file from the `perspectives` folder, and drop the matching `ref perspective` line from `model.tmdl`. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.\n\n### When to ignore it\n\nThere is no case for it. A perspective you have created and not yet filled is the one you want reported, because nothing else will tell you it is still empty, and an empty perspective in a published model offers a report author a view of the model with no fields in it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PERSPECTIVES_WITH_NO_OBJECTS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PERSPECTIVES_WITH_NO_OBJECTS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule counts the `perspectiveTable` entries the perspective carries, not the objects they resolve to. A perspective that lists a table which was deleted is not empty and is not reported.\n\nRead more: https://pbiplint.com/rules/perspectives-with-no-objects"
11271
11568
  },
11272
11569
  PROVIDE_FORMAT_STRING_FOR_MEASURES: {
11273
11570
  text: `Example
@@ -11297,7 +11594,7 @@ table Sales
11297
11594
 
11298
11595
  Why it matters
11299
11596
 
11300
- A measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.
11597
+ A measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone, and so is one whose DAX plainly returns text, which has nothing to format.
11301
11598
 
11302
11599
  How to fix it
11303
11600
 
@@ -11305,19 +11602,21 @@ In Power BI Desktop, select the measure in the Data pane and set Format under Me
11305
11602
 
11306
11603
  When to ignore it
11307
11604
 
11308
- A measure that returns text has nothing to format. A label measure that builds a title, and a measure that returns a hex color for conditional formatting, are both reported here and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.
11605
+ A measure that returns text has nothing to format. pbiplint leaves out the ones whose DAX shows it plainly (see Quirks), but a label that comes from a column, such as MAXX over a text column, and a measure that picks a hex color for conditional formatting with SWITCH or IF, are still reported, and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.
11309
11606
 
11310
11607
  To ignore this rule on one object, add annotation pbiplint.ignore = PROVIDE_FORMAT_STRING_FOR_MEASURES under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "PROVIDE_FORMAT_STRING_FOR_MEASURES": "off" under rules in pbiplint.config.json.
11311
11608
 
11312
11609
  Quirks
11313
11610
 
11611
+ - A measure that plainly returns text is not reported: one whose result, after its last top-level RETURN, is a lone string, joins values with &, or starts with a function that returns text, such as FORMAT or CONCATENATEX. The source rule does not read what a measure returns, so Tabular Editor reports such a measure. Power BI has no format string for text: "You can't set a custom format string for fields that are of type string or Boolean" (Use custom format strings in Power BI Desktop (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#considerations-and-limitations)).
11612
+ - Text returned any other way is still reported: MAXX over a text column, a variable holding text returned by its name, or an IF or SWITCH whose every branch is a string. pbiplint does not work out what type a DAX expression returns, and reads only what the tokens show, so a string or & inside a comment, inside a call or parentheses, or before the last RETURN, does not count. The functions that count, when the result starts with a call to one, are FORMAT, CONCATENATE, CONCATENATEX, UNICHAR, COMBINEVALUES, LEFT, RIGHT, MID, UPPER, LOWER, SUBSTITUTE, REPT, TRIM, FIXED, REPLACE, USERPRINCIPALNAME, USERNAME, USEROBJECTID, USERCULTURE, CUSTOMDATA, SELECTEDMEASURENAME, NAMEOF, TOJSON, and TOCSV.
11314
11613
  - A format string of nothing but spaces counts as no format string, so formatString: " " is reported.
11315
11614
  - A measure with only a dynamic format string passes here but fires INTEGER_FORMATTING, which reads the static format string alone.
11316
11615
  - Hidden measures, and measures on hidden tables, are skipped. INTEGER_FORMATTING skips neither.
11317
11616
  - The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the measure's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
11318
11617
 
11319
11618
  Read more: https://pbiplint.com/rules/provide-format-string-for-measures`,
11320
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.\n\n### How to fix it\n\nIn Power BI Desktop, select the measure in the Data pane and set Format under Measure tools. In the TMDL file, add `formatString` under the measure: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages. Where the format depends on what the measure returns, a dynamic format string satisfies the rule as well: pick Dynamic in the Format list under Measure tools and write the expression in the formula bar, which the file records as a `formatStringDefinition` block under the measure.\n\n### When to ignore it\n\nA measure that returns text has nothing to format. A label measure that builds a title, and a measure that returns a hex color for conditional formatting, are both reported here and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PROVIDE_FORMAT_STRING_FOR_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PROVIDE_FORMAT_STRING_FOR_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A format string of nothing but spaces counts as no format string, so `formatString: \" \"` is reported.\n- A measure with only a dynamic format string passes here but fires `INTEGER_FORMATTING`, which reads the static format string alone.\n- Hidden measures, and measures on hidden tables, are skipped. `INTEGER_FORMATTING` skips neither.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the measure's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures"
11619
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone, and so is one whose DAX plainly returns text, which has nothing to format.\n\n### How to fix it\n\nIn Power BI Desktop, select the measure in the Data pane and set Format under Measure tools. In the TMDL file, add `formatString` under the measure: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages. Where the format depends on what the measure returns, a dynamic format string satisfies the rule as well: pick Dynamic in the Format list under Measure tools and write the expression in the formula bar, which the file records as a `formatStringDefinition` block under the measure.\n\n### When to ignore it\n\nA measure that returns text has nothing to format. pbiplint leaves out the ones whose DAX shows it plainly (see Quirks), but a label that comes from a column, such as `MAXX` over a text column, and a measure that picks a hex color for conditional formatting with `SWITCH` or `IF`, are still reported, and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PROVIDE_FORMAT_STRING_FOR_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PROVIDE_FORMAT_STRING_FOR_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A measure that plainly returns text is not reported: one whose result, after its last top-level `RETURN`, is a lone string, joins values with `&`, or starts with a function that returns text, such as `FORMAT` or `CONCATENATEX`. The source rule does not read what a measure returns, so Tabular Editor reports such a measure. Power BI has no format string for text: \"You can't set a custom format string for fields that are of type string or Boolean\" ([Use custom format strings in Power BI Desktop](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#considerations-and-limitations)).\n- Text returned any other way is still reported: `MAXX` over a text column, a variable holding text returned by its name, or an `IF` or `SWITCH` whose every branch is a string. pbiplint does not work out what type a DAX expression returns, and reads only what the tokens show, so a string or `&` inside a comment, inside a call or parentheses, or before the last `RETURN`, does not count. The functions that count, when the result starts with a call to one, are `FORMAT`, `CONCATENATE`, `CONCATENATEX`, `UNICHAR`, `COMBINEVALUES`, `LEFT`, `RIGHT`, `MID`, `UPPER`, `LOWER`, `SUBSTITUTE`, `REPT`, `TRIM`, `FIXED`, `REPLACE`, `USERPRINCIPALNAME`, `USERNAME`, `USEROBJECTID`, `USERCULTURE`, `CUSTOMDATA`, `SELECTEDMEASURENAME`, `NAMEOF`, `TOJSON`, and `TOCSV`.\n- A format string of nothing but spaces counts as no format string, so `formatString: \" \"` is reported.\n- A measure with only a dynamic format string passes here but fires `INTEGER_FORMATTING`, which reads the static format string alone.\n- Hidden measures, and measures on hidden tables, are skipped. `INTEGER_FORMATTING` skips neither.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the measure's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures"
11321
11620
  },
11322
11621
  REDUCE_ADVANCED_FILTERS: {
11323
11622
  text: `Example
@@ -12358,18 +12657,19 @@ Set the two columns to the same type, and prefer a whole number for a key. In Po
12358
12657
 
12359
12658
  When to ignore it
12360
12659
 
12361
- A column whose declaration carries no dataType at all compares as having none, so a relationship onto a calculated column that was written without the property is reported although both sides may hold the same type once the model is loaded. That is the one finding here worth reading twice, and the answer to it is to write the property rather than to change a type. Where both types are written and they differ, there is nothing to ignore.
12660
+ There is nothing to ignore: where both columns name a type and the types differ, set them to the same type. The rule compares only the types the TMDL names, so a relationship with a calculated column saved with no dataType line is not reported at all (see Quirks).
12362
12661
 
12363
12662
  To ignore this rule on one object, add annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SAME_DATA_TYPE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "RELATIONSHIP_COLUMNS_SAME_DATA_TYPE": "off" under rules in pbiplint.config.json.
12364
12663
 
12365
12664
  Quirks
12366
12665
 
12367
12666
  - Both sides have to resolve to a column the model declares. A relationship naming a table or a column that does not exist is skipped, so a typo in fromColumn or toColumn hides the relationship from this rule.
12368
- - The comparison is on the declared dataType, ignoring letter case. A column with no dataType line compares as having none, and so differs from any column that has one.
12667
+ - The comparison is on the declared dataType, ignoring letter case.
12668
+ - A relationship with a column that has no dataType line on either side, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know that column's type. Tabular Editor reads the type from the column's DAX, so it still reports a real mismatch on such a relationship, which pbiplint misses.
12369
12669
  - Only the declared type is compared, never the values. Two text keys that will never match, one padded and one not, pass the rule.
12370
12670
 
12371
12671
  Read more: https://pbiplint.com/rules/relationship-columns-same-data-type`,
12372
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: string\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\n### How to fix it\n\nSet the two columns to the same type, and prefer a whole number for a key. In Power BI Desktop there are two places to do it: Column tools, Data type on the selected column, which changes the type on the model, or Transform data, where a Changed Type step in Power Query gets the type right before the data is loaded and keeps it right on every refresh. Power Query is the better of the two when the source is the reason the types differ. In the TMDL file the property is `dataType` on each column. Converting a text key to a whole number fails at refresh on any value that is not a number, so look for padded keys such as `007` first and strip the padding in the same Power Query step.\n\n### When to ignore it\n\nA column whose declaration carries no `dataType` at all compares as having none, so a relationship onto a calculated column that was written without the property is reported although both sides may hold the same type once the model is loaded. That is the one finding here worth reading twice, and the answer to it is to write the property rather than to change a type. Where both types are written and they differ, there is nothing to ignore.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SAME_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SAME_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both sides have to resolve to a column the model declares. A relationship naming a table or a column that does not exist is skipped, so a typo in `fromColumn` or `toColumn` hides the relationship from this rule.\n- The comparison is on the declared `dataType`, ignoring letter case. A column with no `dataType` line compares as having none, and so differs from any column that has one.\n- Only the declared type is compared, never the values. Two text keys that will never match, one padded and one not, pass the rule.\n\nRead more: https://pbiplint.com/rules/relationship-columns-same-data-type"
12672
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: string\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\n### How to fix it\n\nSet the two columns to the same type, and prefer a whole number for a key. In Power BI Desktop there are two places to do it: Column tools, Data type on the selected column, which changes the type on the model, or Transform data, where a Changed Type step in Power Query gets the type right before the data is loaded and keeps it right on every refresh. Power Query is the better of the two when the source is the reason the types differ. In the TMDL file the property is `dataType` on each column. Converting a text key to a whole number fails at refresh on any value that is not a number, so look for padded keys such as `007` first and strip the padding in the same Power Query step.\n\n### When to ignore it\n\nThere is nothing to ignore: where both columns name a type and the types differ, set them to the same type. The rule compares only the types the TMDL names, so a relationship with a calculated column saved with no `dataType` line is not reported at all (see Quirks).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SAME_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SAME_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both sides have to resolve to a column the model declares. A relationship naming a table or a column that does not exist is skipped, so a typo in `fromColumn` or `toColumn` hides the relationship from this rule.\n- The comparison is on the declared `dataType`, ignoring letter case.\n- A relationship with a column that has no `dataType` line on either side, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know that column's type. Tabular Editor reads the type from the column's DAX, so it still reports a real mismatch on such a relationship, which pbiplint misses.\n- Only the declared type is compared, never the values. Two text keys that will never match, one padded and one not, pass the rule.\n\nRead more: https://pbiplint.com/rules/relationship-columns-same-data-type"
12373
12673
  },
12374
12674
  RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE: {
12375
12675
  text: `Example
@@ -12434,9 +12734,10 @@ Quirks
12434
12734
  - Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.
12435
12735
  - Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.
12436
12736
  - Inactive relationships count, and so do relationships whose cross-filter direction is both.
12737
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
12437
12738
 
12438
12739
  Read more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type`,
12439
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Customer Code'\n dataType: string\n isHidden\n summarizeBy: none\n sourceColumn: Customer Code\n\ntable Customer\n column 'Customer Code'\n dataType: string\n isKey\n summarizeBy: none\n sourceColumn: Customer Code\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Code'\n toColumn: Customer.'Customer Code'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Customer Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Customer Key\n\ntable Customer\n column 'Customer Key'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer Key\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Key'\n toColumn: Customer.'Customer Key'\n```\n\n### Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\n### How to fix it\n\nThe type has to change on both ends, so the fix belongs upstream of the model. Where the source already has an integer surrogate key, load it instead of the natural key: in Power BI Desktop, choose Transform data, add the key column to the query, and drop the text one. Where there is no integer key, add one in the source view or build it with a merge in Power Query against the dimension. The Data type box under Column tools changes a column's type in place on an import model, which is worth using only when the values are already digits held as text. Change both ends in the same edit, because a relationship whose two columns end up with different types is a finding of its own.\n\n### When to ignore it\n\nA date relationship on a DateTime column is the common one to leave: it is what Power BI Desktop builds when you connect a fact table to a date table, it works, and moving the model to an integer date key such as 20260904 is a project rather than a fix. A small dimension is the second: a few thousand rows keyed on a short text code cost almost nothing, and a surrogate key adds a column to build and maintain for a saving nobody will measure. The finding earns its keep on the fact tables, where the key column is one of the widest things in the model.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Every date relationship on a DateTime column is reported. That is what the source rule does, and it is why a model whose date table is keyed on a DateTime column collects a finding for each end of every date relationship.\n- Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.\n- Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.\n- Inactive relationships count, and so do relationships whose cross-filter direction is both.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type"
12740
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Customer Code'\n dataType: string\n isHidden\n summarizeBy: none\n sourceColumn: Customer Code\n\ntable Customer\n column 'Customer Code'\n dataType: string\n isKey\n summarizeBy: none\n sourceColumn: Customer Code\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Code'\n toColumn: Customer.'Customer Code'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Customer Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Customer Key\n\ntable Customer\n column 'Customer Key'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer Key\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Key'\n toColumn: Customer.'Customer Key'\n```\n\n### Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\n### How to fix it\n\nThe type has to change on both ends, so the fix belongs upstream of the model. Where the source already has an integer surrogate key, load it instead of the natural key: in Power BI Desktop, choose Transform data, add the key column to the query, and drop the text one. Where there is no integer key, add one in the source view or build it with a merge in Power Query against the dimension. The Data type box under Column tools changes a column's type in place on an import model, which is worth using only when the values are already digits held as text. Change both ends in the same edit, because a relationship whose two columns end up with different types is a finding of its own.\n\n### When to ignore it\n\nA date relationship on a DateTime column is the common one to leave: it is what Power BI Desktop builds when you connect a fact table to a date table, it works, and moving the model to an integer date key such as 20260904 is a project rather than a fix. A small dimension is the second: a few thousand rows keyed on a short text code cost almost nothing, and a surrogate key adds a column to build and maintain for a saving nobody will measure. The finding earns its keep on the fact tables, where the key column is one of the widest things in the model.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Every date relationship on a DateTime column is reported. That is what the source rule does, and it is why a model whose date table is keyed on a DateTime column collects a finding for each end of every date relationship.\n- Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.\n- Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.\n- Inactive relationships count, and so do relationships whose cross-filter direction is both.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type"
12440
12741
  },
12441
12742
  "REMOVE_AUTO-DATE_TABLE": {
12442
12743
  text: `Example
@@ -12910,11 +13211,11 @@ table Date
12910
13211
 
12911
13212
  Why it matters
12912
13213
 
12913
- A column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.
13214
+ A column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory. A column a calendar names is read by time intelligence through the calendar, and a primary column also sorts the calendar's periods: "the primary columns are used for sorting" (Primary versus associated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)).
12914
13215
 
12915
13216
  How to fix it
12916
13217
 
12917
- Power BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the isAvailableInMdx: false line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.
13218
+ Power BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the isAvailableInMdx: false line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. For a column a calendar names, take it out of the calendar. In the TMDL file, delete an associated or time-related column's line under its calendarColumnGroup. A primary column's line cannot go on its own, because "The primary column is required for each category" (Primary versus associated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)): remove its whole calendarColumnGroup, or name another column as the group's primaryColumn. Calendar options in Table tools, which Desktop shows only once the Enhanced DAX Time Intelligence preview is turned on (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)), makes the same changes for primary and associated columns, but not for a time-related column: Microsoft says tagging one "isn't currently possible in the calendar options, but can instead only be done using external tools or TMDL" (Available column categories (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#available-column-categories)). Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.
12918
13219
 
12919
13220
  When to ignore it
12920
13221
 
@@ -12927,9 +13228,10 @@ Quirks
12927
13228
  - A column with no isAvailableInMdx line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is false, so this rule fires only where something set the property deliberately.
12928
13229
  - Variations are matched on the default column alone. pbiplint reads a variation's defaultColumn, so a column a variation reaches only through its default hierarchy is not protected here.
12929
13230
  - Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in sortByColumn, whenever the property is false on either.
13231
+ - A column a calendar names, as a primary, associated, or time-related column, is reported when IsAvailableInMdx is false, so the two rules stay mirrors. Tabular Editor 3's built-in version of the rule also reports a primary column a calendar names when its IsAvailableInMdx is false. The source rule does not read calendars.
12930
13232
 
12931
13233
  Read more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns`,
12932
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n sourceColumn: MonthNumber\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n sourceColumn: MonthNumber\n```\n\n### Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.\n\n### How to fix it\n\nPower BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the `isAvailableInMdx: false` line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.\n\n### When to ignore it\n\nThere is no case for leaving it. A column reported here is one the engine needs an attribute hierarchy for, and the memory the property saves on it is small next to a model that fails to process. What is worth working out is why the property is there at all: it is almost always one bulk edit made across every hidden column, and the columns that needed the attribute hierarchy are the ones this rule lists. Clear those and leave the rest alone, so the saving stays where it does no harm.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is `false`, so this rule fires only where something set the property deliberately.\n- Variations are matched on the default column alone. pbiplint reads a variation's `defaultColumn`, so a column a variation reaches only through its default hierarchy is not protected here.\n- Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in `sortByColumn`, whenever the property is false on either.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns"
13234
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n sourceColumn: MonthNumber\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n sourceColumn: MonthNumber\n```\n\n### Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory. A column a calendar names is read by time intelligence through the calendar, and a primary column also sorts the calendar's periods: \"the primary columns are used for sorting\" ([Primary versus associated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)).\n\n### How to fix it\n\nPower BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the `isAvailableInMdx: false` line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. For a column a calendar names, take it out of the calendar. In the TMDL file, delete an associated or time-related column's line under its `calendarColumnGroup`. A primary column's line cannot go on its own, because \"The primary column is required for each category\" ([Primary versus associated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)): remove its whole `calendarColumnGroup`, or name another column as the group's `primaryColumn`. Calendar options in Table tools, which Desktop shows only once the Enhanced DAX Time Intelligence preview is turned on ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)), makes the same changes for primary and associated columns, but not for a time-related column: Microsoft says tagging one \"isn't currently possible in the calendar options, but can instead only be done using external tools or TMDL\" ([Available column categories](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#available-column-categories)). Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.\n\n### When to ignore it\n\nThere is no case for leaving it. A column reported here is one the engine needs an attribute hierarchy for, and the memory the property saves on it is small next to a model that fails to process. What is worth working out is why the property is there at all: it is almost always one bulk edit made across every hidden column, and the columns that needed the attribute hierarchy are the ones this rule lists. Clear those and leave the rest alone, so the saving stays where it does no harm.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is `false`, so this rule fires only where something set the property deliberately.\n- Variations are matched on the default column alone. pbiplint reads a variation's `defaultColumn`, so a column a variation reaches only through its default hierarchy is not protected here.\n- Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in `sortByColumn`, whenever the property is false on either.\n- A column a calendar names, as a primary, associated, or time-related column, is reported when IsAvailableInMdx is false, so the two rules stay mirrors. Tabular Editor 3's built-in version of the rule also reports a primary column a calendar names when its IsAvailableInMdx is false. The source rule does not read calendars.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns"
12933
13235
  },
12934
13236
  SLICER_SEARCH_SAVED: {
12935
13237
  text: `Example
@@ -13819,13 +14121,14 @@ Quirks
13819
14121
  - A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.
13820
14122
  - A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside SUMX ( 'Sales', [Handling Fee] ) but reported the column a function names in MAX ( [Tax Rate] ), though deleting it would break the function.
13821
14123
  - A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its groupByColumn under relatedColumnDetails, and the parameter stops working without it. The source rule does not test groupByColumn, so Tabular Editor reports that column.
14124
+ - A column a calendar names, as a primary, associated, or time-related column, counts as used: the calendar needs it for time intelligence. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden and nothing else uses it.
13822
14125
  - Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.
13823
14126
  - Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS does read variations.
13824
14127
  - Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: Table[Column] or 'Table'[Column] in any role's filter, or [Column] in a filter on the column's own table, counts as a use even inside a comment or a string there.
13825
14128
  - While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
13826
14129
 
13827
14130
  Read more: https://pbiplint.com/rules/unnecessary-columns`,
13828
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, `'Date'[Date].[Year]`, only `'Date'[Date]` is. A bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden `'Archive'[DueDate]` that DAX names only as `[DueDate]` outside `SUMMARIZE ( 'Invoices', 'Invoices'[Key], \"DueDate\", MAX ( 'Invoices'[DueDate] ) )` is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the `[Qty]` in `SUMX ( 'Sales', [Qty] )` is not a use of `'Sales'[Qty]` either.\n- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports that column.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: `Table[Column]` or `'Table'[Column]` in any role's filter, or `[Column]` in a filter on the column's own table, counts as a use even inside a comment or a string there.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
14131
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, `'Date'[Date].[Year]`, only `'Date'[Date]` is. A bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden `'Archive'[DueDate]` that DAX names only as `[DueDate]` outside `SUMMARIZE ( 'Invoices', 'Invoices'[Key], \"DueDate\", MAX ( 'Invoices'[DueDate] ) )` is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the `[Qty]` in `SUMX ( 'Sales', [Qty] )` is not a use of `'Sales'[Qty]` either.\n- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports that column.\n- A column a calendar names, as a primary, associated, or time-related column, counts as used: the calendar needs it for time intelligence. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden and nothing else uses it.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: `Table[Column]` or `'Table'[Column]` in any role's filter, or `[Column]` in a filter on the column's own table, counts as a use even inside a comment or a string there.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
13829
14132
  },
13830
14133
  UNNECESSARY_MEASURES: {
13831
14134
  text: `Example
@@ -13970,10 +14273,11 @@ Quirks
13970
14273
  - Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.
13971
14274
  - The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.
13972
14275
  - The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.
14276
+ - A column with no dataType line, as Power BI Desktop saves most calculated columns, does not count as a numeric month column, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
13973
14277
  - Tables and calculated tables are in scope; calculation groups are not.
13974
14278
 
13975
14279
  Read more: https://pbiplint.com/rules/unpivot-pivoted-month-data`,
13976
- markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column Jan\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jan\n\n column Feb\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Feb\n\n column Mar\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Mar\n\n column Apr\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Apr\n\n column May\n dataType: decimal\n summarizeBy: sum\n sourceColumn: May\n\n column Jun\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jun\n```\n\n**After the fix**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: MonthName\n sortByColumn: 'Month Number'\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: MonthNumber\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n sourceColumn: MonthStart\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, with the month's start date beside them, the same data relates to the date table through that date and every measure and time intelligence function works over it.\n\n### How to fix it\n\nReshape the table where it is loaded. In Power BI Desktop choose Transform data, select the query, select the month columns, and use Unpivot Columns on the Transform tab, or select the columns that are not months and use Unpivot Other Columns so next year's column is picked up without an edit; then rename the Attribute and Value columns to something a report author will recognize, such as Month Name and Amount. Where the source is a warehouse, the same reshape belongs in a view there, and the refresh gets the finished shape for nothing. Back in the model, give the month column a Month Number column to sort by, which is Sort by column on the Column tools tab and `sortByColumn` in the TMDL file. A month name is text and cannot carry a relationship to a day-grain date table, so load the month's start date alongside it, as a Month Start column of the first of each month, and relate that column to the date table; time intelligence then works over the reshaped table. The rule reads the model's columns, so the finding clears as soon as the reshaped query is applied.\n\n### When to ignore it\n\nA coincidence is the case to check for first, though it takes six of them at once. The month names are matched as substrings of the column names, so Janitorial Cost satisfies Jan, Margin satisfies Mar, Apron Sales satisfies Apr, and Junior Rate satisfies Jun; a table of numeric metrics carrying one such name for each of the six months is reported with no month in it anywhere. Read the column names before reshaping anything. A genuinely pivoted table can also be deliberate: a small budget entry table that a person maintains by hand in a spreadsheet is easier to fill in wide, and where it is a handful of rows, unpivoting it on the way in costs nothing and unpivoting it at the source costs an argument. What is not a legitimate exception is a wide fact table of months feeding visuals through twelve near-identical measures, which is the pattern the rule exists to catch.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNPIVOT_PIVOTED_(MONTH)_DATA` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNPIVOT_PIVOTED_(MONTH)_DATA\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.\n- The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.\n- The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.\n- Tables and calculated tables are in scope; calculation groups are not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data"
14280
+ markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column Jan\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jan\n\n column Feb\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Feb\n\n column Mar\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Mar\n\n column Apr\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Apr\n\n column May\n dataType: decimal\n summarizeBy: sum\n sourceColumn: May\n\n column Jun\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jun\n```\n\n**After the fix**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: MonthName\n sortByColumn: 'Month Number'\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: MonthNumber\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n sourceColumn: MonthStart\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, with the month's start date beside them, the same data relates to the date table through that date and every measure and time intelligence function works over it.\n\n### How to fix it\n\nReshape the table where it is loaded. In Power BI Desktop choose Transform data, select the query, select the month columns, and use Unpivot Columns on the Transform tab, or select the columns that are not months and use Unpivot Other Columns so next year's column is picked up without an edit; then rename the Attribute and Value columns to something a report author will recognize, such as Month Name and Amount. Where the source is a warehouse, the same reshape belongs in a view there, and the refresh gets the finished shape for nothing. Back in the model, give the month column a Month Number column to sort by, which is Sort by column on the Column tools tab and `sortByColumn` in the TMDL file. A month name is text and cannot carry a relationship to a day-grain date table, so load the month's start date alongside it, as a Month Start column of the first of each month, and relate that column to the date table; time intelligence then works over the reshaped table. The rule reads the model's columns, so the finding clears as soon as the reshaped query is applied.\n\n### When to ignore it\n\nA coincidence is the case to check for first, though it takes six of them at once. The month names are matched as substrings of the column names, so Janitorial Cost satisfies Jan, Margin satisfies Mar, Apron Sales satisfies Apr, and Junior Rate satisfies Jun; a table of numeric metrics carrying one such name for each of the six months is reported with no month in it anywhere. Read the column names before reshaping anything. A genuinely pivoted table can also be deliberate: a small budget entry table that a person maintains by hand in a spreadsheet is easier to fill in wide, and where it is a handful of rows, unpivoting it on the way in costs nothing and unpivoting it at the source costs an argument. What is not a legitimate exception is a wide fact table of months feeding visuals through twelve near-identical measures, which is the pattern the rule exists to catch.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNPIVOT_PIVOTED_(MONTH)_DATA` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNPIVOT_PIVOTED_(MONTH)_DATA\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.\n- The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.\n- The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, does not count as a numeric month column, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- Tables and calculated tables are in scope; calculation groups are not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data"
13977
14281
  },
13978
14282
  USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: {
13979
14283
  text: `Example
@@ -14636,7 +14940,7 @@ function readFolder(w, input, path, preferred) {
14636
14940
  }
14637
14941
 
14638
14942
  // src/main.ts
14639
- var VERSION2 = true ? "0.2.2" : "0.0.0-dev";
14943
+ var VERSION2 = true ? "0.2.3" : "0.0.0-dev";
14640
14944
  function listRules() {
14641
14945
  const width = Math.max(...defaultRules.map((r) => r.id.length));
14642
14946
  return defaultRules.map(