Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e1e0145
feat: add sap-description-column-label ESLint rule
marcovieth Apr 10, 2026
f719c88
Merge remote-tracking branch 'origin/main' into feat/rule-description…
marcovieth Apr 13, 2026
9107455
add JSDoc to some functions
marcovieth Apr 13, 2026
5418ac5
feat: enhance OData V2 support by injecting inline sap:text and sap:l…
marcovieth Apr 15, 2026
63b54e6
added tests for sap:text and sap:label in V2 property parsing
marcovieth Apr 15, 2026
1503f4f
update changeset
marcovieth Apr 15, 2026
ffe2818
feat: add V2 rule tests for inline sap:text and sap:label
marcovieth Apr 15, 2026
cc3cfa5
docs: add V2 examples, use writing style "xxx annotation"
marcovieth Apr 15, 2026
025449a
fix: update import path for AnnotationFile type in service tests
marcovieth Apr 15, 2026
90a3db5
docs: enhance rule description
marcovieth Apr 17, 2026
92eebfa
Merge branch 'main' into feat/rule-descriptionColumnLabel
marcovieth Apr 17, 2026
583d5d4
refactor: move utility functions to common-text-helpers module
marcovieth Apr 17, 2026
69b05b0
Merge branch 'main' into feat/rule-descriptionColumnLabel
marcovieth Apr 20, 2026
beb471f
fix: handle undefined label annotations and improve trivial label che…
marcovieth Apr 20, 2026
62b4955
Merge branch 'main' into feat/rule-descriptionColumnLabel
marcovieth Apr 21, 2026
49af900
docs: removed separate Examples section and improved writing style in…
marcovieth Apr 22, 2026
d0f7c68
refactor: simplify annotation key parsing and improve utility functions
marcovieth Apr 22, 2026
0c4984c
Merge branch 'main' into feat/rule-descriptionColumnLabel
marcovieth Apr 22, 2026
3fa3d9d
fix: replace annotation keys with fully-qualified term names in tests
marcovieth Apr 22, 2026
5d8bc7a
refactor: show issues on the Common.Label annotation and not on the C…
marcovieth Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rich-meals-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@sap-ux/xml-odata-annotation-converter': minor
'@sap-ux/odata-annotation-core-types': minor
'@sap-ux/eslint-plugin-fiori-tools': minor
---

[rule] Add rule to check that a Common.Text description property has a meaningful Common.Label annotation
3 changes: 2 additions & 1 deletion packages/eslint-plugin-fiori-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ npx --yes @sap-ux/create@latest convert eslint-config --help

| Since | Rule | Description | Recommended | Recommended for S/4HANA |
|:---------:|------|-------------|:-----------:|:-----------------------:|
| new | [sap-text-arrangement-hidden](docs/rules/sap-text-arrangement-hidden.md) | Ensures that the text property referenced by a `UI.TextArrangement` annotation using the `Common.Text` annotation is not hidden by the `UI.Hidden` annotation | | ✅ |
| new | [sap-description-column-label](docs/rules/sap-description-column-label.md) | Ensures that the description (text) property referenced via `Common.Text` has a meaningful `Common.Label` — not a generic value such as `"Name"` or `"Description"`, and not the same label as the ID property. | | ✅ |
| 9.12.0 | [sap-text-arrangement-hidden](docs/rules/sap-text-arrangement-hidden.md) | Ensures that the text property referenced by a `UI.TextArrangement` annotation using the `Common.Text` annotation is not hidden by the `UI.Hidden` annotation | | ✅ |
| 9.11.0 | [sap-no-data-field-intent-based-navigation](docs/rules/sap-no-data-field-intent-based-navigation.md) | Ensures neither `DataFieldForIntentBasedNavigation` nor `DataFieldWithIntentBasedNavigation` are used in tables or form fields in SAP Fiori elements applications. | | ✅ |
| 9.10.0 | [sap-condensed-table-layout](docs/rules/sap-condensed-table-layout.md) | Requires `condensedTableLayout` to be enabled when using a grid table, analytical table, or tree table. | | ✅ |
| 9.9.0 | [sap-strict-uom-filtering](docs/rules/sap-strict-uom-filtering.md) | Ensures that `disableStrictUomFiltering` is not set to `true` in `sap.fe.app` manifest configuration | | ✅ |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Require Meaningful Labels for Description (Text) Properties (`sap-description-column-label`)

Ensures that the property referenced as the description (text) value using the `Common.Text` annotation has a meaningful `Common.Label` annotation. The label must not be a generic value such as `"Name"` or `"Description"` nor the same label as the ID property it describes.

## Rule Details

When a field uses the `Common.Text` annotation to associate a human-readable description property with a technical key property, the description property must carry a `Common.Label` annotation that clearly identifies its content. A generic label such as `"Name"` or `"Description"` provides no useful context to the user in the UI and a label that is identical to the ID property's label creates ambiguity.

The rule checks every `Common.Text` annotation on a property that belongs to an entity type used in the application, that is linked to at least one page in the `manifest.json` file. For each annotation, the rule performs the following:

1. Resolves the path to the referenced text property which includes navigation segments such as `category/name`.
2. Reads the `Common.Label` annotation on that property.
3. Produces a `trivialLabel` warning if the label is the same as `"Name"` or `"Description"` (case-insensitive).
4. Produces a `duplicateLabel` warning if the label is identical (case-insensitive) to the `Common.Label` annotation of the ID property that carries the `Common.Text` annotation.

If the text property has no `Common.Label` annotation, the rule does not produce a warning.

> **OData V2**: This rule also applies to OData V2 services. In OData V2 metadata, the `sap:text` attribute on a `Property` element is treated as an implicit `Common.Text` annotation and the `sap:label` attribute is treated as an implicit `Common.Label` annotation. The rule reports on the metadata file directly when these inline attributes produce a trivial or duplicate label.

### Why Was This Rule Introduced?

When a property is displayed using a description column, for example, `TextArrangement/TextOnly`, the column header comes from the `Common.Label` annotation of the text property. A label of `"Name"` or `"Description"` is semantically meaningless in that context, it does not tell the user which name or description is shown. Similarly, if the description column carries the exact same label as the ID column, the user cannot distinguish the two columns.

### Warning Messages

`trivialLabel`
```
The "{{textPropertyTarget}}" text property has a "{{textPropertyLabel}}" generic label. Use a more descriptive label that distinguishes it from other properties.
```

`duplicateLabel`
```
The "{{textPropertyTarget}}" text property has the same "{{textPropertyLabel}}" label as the "{{idPropertyTarget}}" ID property. The description column label must be different from the ID label.
```

### Incorrect Annotations

`trivialLabel`: The `Common.Label` annotation on the text property is too generic:

```xml
<!-- ProductBaseUnit uses UnitOfMeasure_Text as its description column using the Common.Text annotation -->
<Annotations Target="MyService.MyEntity/ProductBaseUnit">
<Annotation Term="Common.Text" Path="to_BaseUnit/UnitOfMeasure_Text"/>
</Annotations>

<!-- The Common.Label annotation on the description property is "Name" which is too generic -->
<Annotations Target="MyService.BaseUnitType/UnitOfMeasure_Text">
<Annotation Term="Common.Label" String="Name"/>
</Annotations>
```

`duplicateLabel`: The `Common.Label` annotation on the text property is identical to the ID property label:

```xml
<!-- Supplier uses CompanyName as its description column using the Common.Text annotation but both properties have the "Supplier" label -->
<Annotations Target="MyService.MyEntity/Supplier">
<Annotation Term="Common.Text" Path="to_Supplier/CompanyName"/>
<Annotation Term="Common.Label" String="Supplier"/>
</Annotations>

<!-- Common.Label annotation identical to the ID property above which leads to ambiguous column headers -->
<Annotations Target="MyService.SupplierType/CompanyName">
<Annotation Term="Common.Label" String="Supplier"/>
</Annotations>
```

`trivialLabel` for OData V2: The inline `sap:label` on the text property is too generic. This is reported on the metadata file:

```xml
<!-- sap:text attribute acts as an implicit Common.Text annotation -->
<Property Name="Product" Type="Edm.String" sap:text="ProductName" sap:label="Product ID" />
<!-- sap:label="Name" acts as an implicit Common.Label annotation — too generic -->
<Property Name="ProductName" Type="Edm.String" sap:label="Name" />
```

`duplicateLabel` for OData V2: The inline `sap:label` values on ID and text property are identical:

```xml
<!-- Both properties carry sap:label="Quantity Unit" using the inline sap:label attribute -->
<Property Name="QuantityUnit" Type="Edm.String" sap:text="QuantityUnitT" sap:label="Quantity Unit" />
<Property Name="QuantityUnitT" Type="Edm.String" sap:label="Quantity Unit" />
```

### Correct Annotations

The `Common.Label` annotation on the text property is meaningful and unique:

```xml
<Annotations Target="MyService.MyEntity/ProductBaseUnit">
<Annotation Term="Common.Text" Path="to_BaseUnit/UnitOfMeasure_Text"/>
</Annotations>

<!-- Common.Label annotation with a descriptive value that distinguishes the column from the ID column -->
<Annotations Target="MyService.BaseUnitType/UnitOfMeasure_Text">
<Annotation Term="Common.Label" String="Unit of Measure"/>
</Annotations>
```

The `Common.Label` annotations on ID and text properties are unique:

```xml
<Annotations Target="MyService.MyEntity/Supplier">
<Annotation Term="Common.Text" Path="to_Supplier/CompanyName"/>
<Annotation Term="Common.Label" String="Supplier ID"/>
</Annotations>

<Annotations Target="MyService.SupplierType/CompanyName">
<Annotation Term="Common.Label" String="Company Name"/>
</Annotations>
```

The `sap:label` values on ID and text property for OData V2 are unique:

```xml
<Property Name="QuantityUnit" Type="Edm.String" sap:text="QuantityUnitT" sap:label="Quantity Unit" />
<!-- The sap:label is unique which provides unambiguous column headers -->
<Property Name="QuantityUnitT" Type="Edm.String" sap:label="Unit of Measure Text" />
```

## Bug Report

If you encounter an issue with this rule, please open a [GitHub issue](https://github.com/SAP/open-ux-tools/issues).

## When Not to Disable This Rule

This rule must not be disabled unless you have a confirmed back-end constraint that prevents renaming the label. Update the `Common.Label` annotation or the `sap:label` attribute for OData V2 on the text property to something meaningful and distinct from the ID property label.

## Further Reading

- [UI5 Further Features of the Field - OData V4](https://ui5.sap.com/#/topic/f49a0f7eaafe444daf4cd62d48120ad0)
- [UI5 Displaying Text and ID for Value Help Input Fields - OData V2](https://ui5.sap.com/#/topic/080886d8d4af4ac6a68a476beab17da3)
- [OData Common Vocabulary - Text](https://github.com/SAP/odata-vocabularies/blob/main/vocabularies/Common.md#Text)
- [OData Common Vocabulary - Label](https://github.com/SAP/odata-vocabularies/blob/main/vocabularies/Common.md#Label)
1 change: 1 addition & 0 deletions packages/eslint-plugin-fiori-tools/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const UI_LINE_ITEM = 'com.sap.vocabularies.UI.v1.LineItem';
export const COMMON_TEXT = 'com.sap.vocabularies.Common.v1.Text';
export const COMMON_LABEL = 'com.sap.vocabularies.Common.v1.Label';
export const UI_HIDDEN = 'com.sap.vocabularies.UI.v1.Hidden';
export const UI_TEXT_ARRANGEMENT = 'com.sap.vocabularies.UI.v1.TextArrangement';
export const UI_FIELD_GROUP = 'com.sap.vocabularies.UI.v1.FieldGroup';
1 change: 1 addition & 0 deletions packages/eslint-plugin-fiori-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ const fioriLanguageConfig: Linter.Config[] = [
'@sap-ux/fiori-tools/sap-flex-enabled': 'warn',
'@sap-ux/fiori-tools/sap-width-including-column-header': 'warn',
'@sap-ux/fiori-tools/sap-copy-to-clipboard': 'warn',
'@sap-ux/fiori-tools/sap-description-column-label': 'warn',
'@sap-ux/fiori-tools/sap-enable-export': 'warn',
'@sap-ux/fiori-tools/sap-enable-paste': 'warn',
'@sap-ux/fiori-tools/sap-creation-mode-for-table': 'warn',
Expand Down
20 changes: 20 additions & 0 deletions packages/eslint-plugin-fiori-tools/src/language/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const TEXT_ARRANGEMENT_HIDDEN = 'sap-text-arrangement-hidden';
export const NO_DATA_FIELD_INTENT_BASED_NAVIGATION = 'sap-no-data-field-intent-based-navigation';
export const CONDENSED_TABLE_LAYOUT = 'sap-condensed-table-layout';
export const STRICT_UOM_FILTERING = 'sap-strict-uom-filtering';
export const DESCRIPTION_COLUMN_LABEL = 'sap-description-column-label';

export interface WidthIncludingColumnHeaderDiagnostic {
type: typeof WIDTH_INCLUDING_COLUMN_HEADER_RULE_TYPE;
Expand Down Expand Up @@ -138,6 +139,24 @@ export interface StrictUomFiltering {
manifest: ManifestPropertyDiagnosticData;
}

export type DescriptionColumnLabelMessageId =
| 'trivialLabel' // label is "Name" or "Description"
| 'duplicateLabel'; // label of text property matches label of ID property

export interface DescriptionColumnLabel {
type: typeof DESCRIPTION_COLUMN_LABEL;
messageId: DescriptionColumnLabelMessageId;
pageNames: string[];
annotation: {
/** Reference to the Common.Label annotation of the text property (the reported node) */
reference: AnnotationReference;
idPropertyTarget: string;
textPropertyTarget: string;
textPropertyLabel: string;
idPropertyLabel?: string;
};
}

export interface TextArrangementHidden {
type: typeof TEXT_ARRANGEMENT_HIDDEN;
pageNames: string[];
Expand All @@ -154,6 +173,7 @@ export type Diagnostic =
| FlexEnabled
| CopyToClipboard
| CreationModeForTable
| DescriptionColumnLabel
| EnableExport
| EnablePaste
| StatePreservationMode
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import type { Element, MetadataElement } from '@sap-ux/odata-annotation-core';
import type { Element, MetadataElement, Range, Target } from '@sap-ux/odata-annotation-core';
import {
Edm,
getAliasInformation,
getAllNamespacesAndReferences,
getElementAttribute,
getElementAttributeValue,
parseIdentifier,
toFullyQualifiedName
toFullyQualifiedName,
createAttributeNode,
createElementNode
} from '@sap-ux/odata-annotation-core';
import type { ServiceArtifacts } from '@sap-ux/fiori-annotation-api/src/types';

import type { DocumentType } from '../types';
import { COMMON_LABEL, COMMON_TEXT } from '../../constants';

export interface ServiceIndex {
entityContainer?: MetadataElement;
Expand Down Expand Up @@ -131,9 +134,112 @@ function processTargetAnnotations(
}
}

/**
* Creates a minimal synthetic Element node carrying a single string attribute.
* Attaches an ESLint-compatible `loc` object when source range is available,
* enabling ESLint to report at the correct line/column in the metadata file.
*
* @param attrName - Attribute name on the Annotation element (e.g. "Path" or "String")
* @param attrValue - Attribute value
* @param sourceRange - Optional source range (0-indexed) used for ESLint reporting
* @returns A synthetic Element, optionally with position range
*/
function createSyntheticElement(attrName: string, attrValue: string, sourceRange?: Range): Element {
const element = createElementNode({
name: Edm.Annotation,
attributes: {
[attrName]: createAttributeNode(attrName, attrValue)
},
content: []
});
if (sourceRange) {
element.range = sourceRange;
}
return element;
}

/**
* Injects synthetic Common.Text and Common.Label annotation entries derived from OData V2
* inline `sap:text` and `sap:label` attributes on Property elements.
*
* Both Common.Text and Common.Label are injected into the annotation index AND the annotation
* file AST so that ESLint's `createAnnotations()` traversal can fire and report diagnostics
* on either annotation type.
*
* Only adds entries when no explicit vocabulary annotation already exists for that key.
*
* @param artifacts - Service artifacts whose metadata properties are to be inspected
* @param metadataUri - URI of the metadata file (used as the annotation source)
* @param index - The annotation index to inject into
*/
function injectV2InlineAnnotations(artifacts: ServiceArtifacts, metadataUri: string, index: AnnotationIndex): void {
if (artifacts.metadataService.ODataVersion !== '2.0') {
return;
}
const annotationFile = artifacts.annotationFiles[metadataUri];
if (!annotationFile) {
return;
}

artifacts.metadataService.visitMetadataElements((element) => {
if (element.kind !== Edm.Property) {
return;
}
const propertyTarget = element.path; // e.g. "Namespace.EntityType/PropertyName"

if (element.sapText) {
const textKey = buildAnnotationIndexKey(propertyTarget, COMMON_TEXT);
if (!index[textKey]) {
const syntheticElement = createSyntheticElement(Edm.Path, element.sapText, element.sapTextRange);
index[textKey] = {
undefined: {
source: metadataUri,
target: propertyTarget,
term: COMMON_TEXT,
top: { uri: metadataUri, value: syntheticElement },
layers: [{ uri: metadataUri, value: syntheticElement }]
}
};
// Also inject into the annotation file AST so the traversal selector fires
const syntheticTarget: Target = {
type: 'target',
name: propertyTarget,
terms: [syntheticElement]
};
annotationFile.targets.push(syntheticTarget);
}
}

if (element.sapLabel) {
const labelKey = buildAnnotationIndexKey(propertyTarget, COMMON_LABEL);
if (!index[labelKey]) {
const syntheticElement = createSyntheticElement(Edm.String, element.sapLabel, element.sapLabelRange);
index[labelKey] = {
undefined: {
source: metadataUri,
target: propertyTarget,
term: COMMON_LABEL,
top: { uri: metadataUri, value: syntheticElement },
layers: [{ uri: metadataUri, value: syntheticElement }]
}
};
// Also inject into the annotation file AST so the traversal selector fires on the label node
const syntheticTarget: Target = {
type: 'target',
name: propertyTarget,
terms: [syntheticElement]
};
annotationFile.targets.push(syntheticTarget);
}
}
});
}

/**
* Builds a service index from service artifacts.
* Creates indexes for entity sets, entity containers, and annotations.
* For OData V2 services, also injects synthetic index entries and AST targets from
* inline sap:text/sap:label attributes so that annotation rules can report on metadata.xml.
*
* @param artifacts - Service artifacts to index
* @param documents - Document map to populate with annotation files
Expand All @@ -150,14 +256,21 @@ export function buildServiceIndex(

const annotationIndex = indexAnnotationsByAnnotationPath(artifacts);
let entityContainer: MetadataElement | undefined;
let metadataUri = '';
artifacts.metadataService.visitMetadataElements((element) => {
// NOSONAR - TODO: check if we can handle CDS differences better
if (element.kind === 'EntityContainer' || element.kind === 'service') {
entityContainer = element;
} else if (element.kind === 'EntitySet' || element.kind === 'entitySet') {
entitySets[element.name] = element;
}
if (!metadataUri && element.location?.uri) {
metadataUri = element.location.uri;
}
});

injectV2InlineAnnotations(artifacts, metadataUri, annotationIndex);

return {
entitySets: entitySets,
entityContainer,
Expand Down
3 changes: 3 additions & 0 deletions packages/eslint-plugin-fiori-tools/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
WIDTH_INCLUDING_COLUMN_HEADER_RULE_TYPE,
COPY_TO_CLIPBOARD,
CREATION_MODE_FOR_TABLE,
DESCRIPTION_COLUMN_LABEL,
ENABLE_EXPORT,
ENABLE_PASTE,
STATE_PRESERVATION_MODE,
Expand Down Expand Up @@ -74,6 +75,7 @@ import creationModeForTable from './sap-creation-mode-for-table';
import statePreservationMode from './sap-state-preservation-mode';
import strictUomFilteringRule from './sap-strict-uom-filtering';
import copyToClipboard from './sap-copy-to-clipboard';
import descriptionColumnLabel from './sap-description-column-label';
import enableExport from './sap-enable-export';
import enablePaste from './sap-enable-paste';
import tablePersonalization from './sap-table-personalization';
Expand Down Expand Up @@ -137,6 +139,7 @@ export const rules: Record<string, Rule.RuleModule | FioriRuleDefinition | Fiori
[FLEX_ENABLED]: flexEnabledRule,
[WIDTH_INCLUDING_COLUMN_HEADER_RULE_TYPE]: requireWidthIncludingColumnHeader,
[COPY_TO_CLIPBOARD]: copyToClipboard,
[DESCRIPTION_COLUMN_LABEL]: descriptionColumnLabel,
[ENABLE_EXPORT]: enableExport,
[ENABLE_PASTE]: enablePaste,
[CREATION_MODE_FOR_TABLE]: creationModeForTable,
Expand Down
Loading
Loading